The smokesignal.events web application

feature: lfg profile creation on home page activity feed

+207 -18
+162 -14
src/http/handle_index.rs
··· 65 65 updated_at: String, 66 66 count: usize, 67 67 }, 68 + #[serde(rename = "grouped_lfg")] 69 + GroupedLfg { 70 + handles: Vec<String>, 71 + dids: Vec<String>, 72 + updated_at: String, 73 + count: usize, 74 + }, 68 75 } 69 76 70 77 #[derive(Serialize)] ··· 77 84 pub activities: Vec<ActivityDisplay>, 78 85 } 79 86 87 + /// LFG activities grouped by H3 cell location 88 + #[derive(Serialize)] 89 + pub struct LfgGroup { 90 + pub activities: Vec<ActivityDisplay>, 91 + } 92 + 80 93 #[derive(Serialize)] 81 94 pub struct DateGroup { 82 95 pub date: String, // e.g., "November 10, 2025" 83 96 pub date_short: String, // e.g., "Nov 10" 84 97 pub event_groups: Vec<EventGroup>, 98 + pub lfg_groups: Vec<LfgGroup>, 85 99 } 86 100 87 101 impl fmt::Display for HomeTab { ··· 145 159 .take(page_size as usize + 1) 146 160 .collect(); 147 161 148 - // Step 1: Group by date, then by event 162 + // Step 1: Group by date, then by event (for events/RSVPs) or by h3_cell (for LFG) 149 163 // Use BTreeMap with Reverse ordering to get newest dates first 150 - let mut date_groups: BTreeMap<std::cmp::Reverse<chrono::NaiveDate>, HashMap<String, Vec<&crate::storage::event::model::ActivityItem>>> = BTreeMap::new(); 164 + let mut date_event_groups: BTreeMap<std::cmp::Reverse<chrono::NaiveDate>, HashMap<String, Vec<&crate::storage::event::model::ActivityItem>>> = BTreeMap::new(); 165 + let mut date_lfg_groups: BTreeMap<std::cmp::Reverse<chrono::NaiveDate>, HashMap<String, Vec<&crate::storage::event::model::ActivityItem>>> = BTreeMap::new(); 151 166 152 167 for activity in &activity_list { 153 168 let date = activity.discovered_at.date_naive(); 154 - let event_aturi = activity.event_aturi.clone(); 155 169 156 - date_groups 157 - .entry(std::cmp::Reverse(date)) 158 - .or_default() 159 - .entry(event_aturi) 160 - .or_default() 161 - .push(activity); 170 + if activity.activity_type == "lfg_created" { 171 + // Group LFG activities by h3_cell 172 + let h3_cell = activity.h3_cell.clone().unwrap_or_default(); 173 + date_lfg_groups 174 + .entry(std::cmp::Reverse(date)) 175 + .or_default() 176 + .entry(h3_cell) 177 + .or_default() 178 + .push(activity); 179 + } else { 180 + // Group event/RSVP activities by event_aturi 181 + let event_aturi = activity.event_aturi.clone(); 182 + date_event_groups 183 + .entry(std::cmp::Reverse(date)) 184 + .or_default() 185 + .entry(event_aturi) 186 + .or_default() 187 + .push(activity); 188 + } 162 189 } 163 190 164 191 // Step 2: Collect all needed DIDs ··· 173 200 let handles = handles_by_did(&web_context.pool, needed_dids.into_iter().collect()).await?; 174 201 175 202 // Step 3: Build date groups with activities 203 + // Collect all unique dates from both event and LFG groups 204 + let mut all_dates: std::collections::BTreeSet<std::cmp::Reverse<chrono::NaiveDate>> = std::collections::BTreeSet::new(); 205 + for key in date_event_groups.keys() { 206 + all_dates.insert(*key); 207 + } 208 + for key in date_lfg_groups.keys() { 209 + all_dates.insert(*key); 210 + } 211 + 176 212 let mut date_group_displays: Vec<DateGroup> = Vec::new(); 177 213 178 - for (rev_date, event_map) in date_groups { 214 + for rev_date in all_dates { 215 + let event_map = date_event_groups.remove(&rev_date).unwrap_or_default(); 216 + let lfg_map = date_lfg_groups.remove(&rev_date).unwrap_or_default(); 179 217 let date = rev_date.0; // Extract date from Reverse wrapper 180 218 181 219 // Format date for display ··· 320 358 let a_time = match a { 321 359 ActivityDisplay::Individual(view) => chrono::DateTime::parse_from_rfc3339(&view.updated_at).ok(), 322 360 ActivityDisplay::GroupedRsvp { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(), 361 + ActivityDisplay::GroupedLfg { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(), 323 362 }; 324 363 let b_time = match b { 325 364 ActivityDisplay::Individual(view) => chrono::DateTime::parse_from_rfc3339(&view.updated_at).ok(), 326 365 ActivityDisplay::GroupedRsvp { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(), 366 + ActivityDisplay::GroupedLfg { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(), 327 367 }; 328 368 b_time.cmp(&a_time) // Descending order: newer timestamps first 329 369 }); ··· 350 390 } 351 391 } 352 392 393 + // Sort event groups by their first activity's timestamp, newest first 353 394 if !event_groups.is_empty() { 354 - // Sort event groups by their first activity's timestamp, newest first 355 395 event_groups.sort_by(|a, b| { 356 396 let a_time = a.activities.first().and_then(|act| match act { 357 397 ActivityDisplay::Individual(view) => chrono::DateTime::parse_from_rfc3339(&view.updated_at).ok(), 358 398 ActivityDisplay::GroupedRsvp { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(), 399 + ActivityDisplay::GroupedLfg { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(), 359 400 }); 360 401 let b_time = b.activities.first().and_then(|act| match act { 361 402 ActivityDisplay::Individual(view) => chrono::DateTime::parse_from_rfc3339(&view.updated_at).ok(), 362 403 ActivityDisplay::GroupedRsvp { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(), 404 + ActivityDisplay::GroupedLfg { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(), 363 405 }); 364 406 b_time.cmp(&a_time) // Descending order: newer timestamps first 365 407 }); 408 + } 366 409 410 + // Process LFG groups (grouped by H3 cell location) 411 + let mut lfg_groups: Vec<LfgGroup> = Vec::new(); 412 + 413 + for (_h3_cell, lfg_activities) in lfg_map { 414 + // Sort LFG activities by timestamp, newest first 415 + let mut sorted_activities: Vec<_> = lfg_activities.into_iter().collect(); 416 + sorted_activities.sort_by(|a, b| { 417 + let a_time = a.created_at.unwrap_or(a.discovered_at); 418 + let b_time = b.created_at.unwrap_or(b.discovered_at); 419 + b_time.cmp(&a_time) 420 + }); 421 + 422 + let mut lfg_activity_displays: Vec<ActivityDisplay> = Vec::new(); 423 + 424 + if sorted_activities.len() > 1 { 425 + // Group multiple LFG profiles at same location 426 + let dids: Vec<String> = sorted_activities.iter().map(|a| a.did.clone()).collect(); 427 + let group_handles: Vec<String> = dids 428 + .iter() 429 + .map(|did| { 430 + handles 431 + .get(did) 432 + .map(|profile| profile.handle.clone()) 433 + .unwrap_or_else(|| did.clone()) 434 + }) 435 + .collect(); 436 + 437 + let first_activity = sorted_activities[0]; 438 + let newest_timestamp = first_activity.created_at.unwrap_or(first_activity.discovered_at); 439 + 440 + lfg_activity_displays.push(ActivityDisplay::GroupedLfg { 441 + handles: group_handles, 442 + dids, 443 + updated_at: newest_timestamp.to_rfc3339(), 444 + count: sorted_activities.len(), 445 + }); 446 + } else if let Some(activity) = sorted_activities.first() { 447 + // Single LFG profile 448 + let handle = handles 449 + .get(&activity.did) 450 + .map(|profile| profile.handle.clone()) 451 + .unwrap_or_else(|| activity.did.clone()); 452 + 453 + let timestamp = activity.created_at.unwrap_or(activity.discovered_at); 454 + lfg_activity_displays.push(ActivityDisplay::Individual(ActivityView { 455 + activity_type: activity.activity_type.clone(), 456 + did: activity.did.clone(), 457 + handle, 458 + event_aturi: String::new(), 459 + event_url: String::new(), 460 + event_name: String::new(), 461 + event_organizer_did: String::new(), 462 + event_organizer_handle: String::new(), 463 + rsvp_status: None, 464 + updated_at: timestamp.to_rfc3339(), 465 + })); 466 + } 467 + 468 + if !lfg_activity_displays.is_empty() { 469 + lfg_groups.push(LfgGroup { 470 + activities: lfg_activity_displays, 471 + }); 472 + } 473 + } 474 + 475 + // Sort LFG groups by their first activity's timestamp, newest first 476 + if !lfg_groups.is_empty() { 477 + lfg_groups.sort_by(|a, b| { 478 + let a_time = a.activities.first().and_then(|act| match act { 479 + ActivityDisplay::Individual(view) => chrono::DateTime::parse_from_rfc3339(&view.updated_at).ok(), 480 + ActivityDisplay::GroupedLfg { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(), 481 + _ => None, 482 + }); 483 + let b_time = b.activities.first().and_then(|act| match act { 484 + ActivityDisplay::Individual(view) => chrono::DateTime::parse_from_rfc3339(&view.updated_at).ok(), 485 + ActivityDisplay::GroupedLfg { updated_at, .. } => chrono::DateTime::parse_from_rfc3339(updated_at).ok(), 486 + _ => None, 487 + }); 488 + b_time.cmp(&a_time) 489 + }); 490 + } 491 + 492 + // Only add date group if there are any activities 493 + if !event_groups.is_empty() || !lfg_groups.is_empty() { 367 494 date_group_displays.push(DateGroup { 368 495 date: date_formatted, 369 496 date_short, 370 497 event_groups, 498 + lfg_groups, 371 499 }); 372 500 } 373 501 } 374 502 375 503 let params: Vec<(&str, &str)> = vec![("tab", &tab_name)]; 376 504 377 - // Calculate total activities for pagination 505 + // Calculate total activities for pagination (event groups + LFG groups) 378 506 let total_activities: usize = date_group_displays 379 507 .iter() 380 - .map(|dg| dg.event_groups.iter().map(|eg| eg.activities.len()).sum::<usize>()) 508 + .map(|dg| { 509 + let event_count: usize = dg.event_groups.iter().map(|eg| eg.activities.len()).sum(); 510 + let lfg_count: usize = dg.lfg_groups.iter().map(|lg| lg.activities.len()).sum(); 511 + event_count + lfg_count 512 + }) 381 513 .sum(); 382 514 383 515 let pagination_view = ··· 390 522 if remaining == 0 { 391 523 return false; 392 524 } 525 + // Truncate event groups 393 526 date_group.event_groups.retain_mut(|event_group| { 394 527 if remaining == 0 { 395 528 return false; ··· 404 537 true 405 538 } 406 539 }); 407 - !date_group.event_groups.is_empty() 540 + // Truncate LFG groups 541 + date_group.lfg_groups.retain_mut(|lfg_group| { 542 + if remaining == 0 { 543 + return false; 544 + } 545 + let activities_count = lfg_group.activities.len(); 546 + if activities_count <= remaining { 547 + remaining -= activities_count; 548 + true 549 + } else { 550 + lfg_group.activities.truncate(remaining); 551 + remaining = 0; 552 + true 553 + } 554 + }); 555 + !date_group.event_groups.is_empty() || !date_group.lfg_groups.is_empty() 408 556 }); 409 557 } 410 558
+24 -4
src/storage/event.rs
··· 74 74 75 75 #[derive(Clone, FromRow, Debug, Serialize)] 76 76 pub struct ActivityItem { 77 - pub activity_type: String, // "event_created", "event_updated", or "rsvp" 77 + pub activity_type: String, // "event_created", "event_updated", "rsvp", or "lfg_created" 78 78 pub did: String, 79 79 pub event_aturi: String, 80 80 pub event_name: String, 81 81 pub rsvp_status: Option<String>, 82 82 pub discovered_at: DateTime<Utc>, // When record was first seen by the system 83 83 pub created_at: Option<DateTime<Utc>>, // From the AT Protocol record metadata 84 + pub h3_cell: Option<String>, // H3 cell for LFG location grouping 84 85 } 85 86 } 86 87 ··· 1246 1247 1247 1248 let offset = (page - 1) * page_size; 1248 1249 1249 - // Query combines events and RSVPs, determining if event was created or updated 1250 + // Query combines events, RSVPs, and LFG profile creations 1250 1251 let query = r#" 1251 1252 WITH activity AS ( 1252 1253 -- Events: check if created_at equals updated_at (from JSON) ··· 1262 1263 name as event_name, 1263 1264 NULL::text as rsvp_status, 1264 1265 discovered_at, 1265 - (record->>'created_at')::timestamptz as created_at 1266 + (record->>'created_at')::timestamptz as created_at, 1267 + NULL::text as h3_cell 1266 1268 FROM events 1267 1269 WHERE discovered_at IS NOT NULL 1268 1270 ··· 1276 1278 e.name as event_name, 1277 1279 r.status as rsvp_status, 1278 1280 r.discovered_at, 1279 - NULL::timestamptz as created_at 1281 + NULL::timestamptz as created_at, 1282 + NULL::text as h3_cell 1280 1283 FROM rsvps r 1281 1284 INNER JOIN events e ON e.aturi = r.event_aturi 1282 1285 WHERE r.discovered_at IS NOT NULL 1286 + 1287 + UNION ALL 1288 + 1289 + -- LFG profile creations (only active, only new creations) 1290 + SELECT 1291 + 'lfg_created' as activity_type, 1292 + did, 1293 + aturi as event_aturi, 1294 + '' as event_name, 1295 + NULL::text as rsvp_status, 1296 + indexed_at as discovered_at, 1297 + (record->>'createdAt')::timestamptz as created_at, 1298 + record->'location'->>'value' as h3_cell 1299 + FROM atproto_records 1300 + WHERE collection = 'events.smokesignal.lfg' 1301 + AND (record->>'active')::boolean = true 1302 + AND ABS(EXTRACT(EPOCH FROM (indexed_at - (record->>'createdAt')::timestamptz))) < 60 1283 1303 ) 1284 1304 SELECT * FROM activity 1285 1305 ORDER BY discovered_at DESC
+21
templates/en-us/index.common.html
··· 169 169 </div> 170 170 {% endfor %} 171 171 {% endfor %} 172 + 173 + {% for lfg_group in date_group.lfg_groups %} 174 + {% for activity in lfg_group.activities %} 175 + <div class="mb-2"> 176 + <p> 177 + {% if activity.type == "individual" and activity.activity_type == "lfg_created" %} 178 + <a href="/{{ activity.did }}">@{{ activity.handle }}</a> is <a href="/lfg">looking for activities</a>. 179 + {% elif activity.type == "grouped_lfg" %} 180 + {% set handle_count = activity.handles | length %} 181 + {% if handle_count == 2 %} 182 + <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>. 183 + {% elif handle_count == 3 %} 184 + <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>. 185 + {% else %} 186 + <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>. 187 + {% endif %} 188 + {% endif %} 189 + </p> 190 + </div> 191 + {% endfor %} 192 + {% endfor %} 172 193 </div> 173 194 {% endfor %} 174 195