The smokesignal.events web application

refactor: performance and usability improvements

+149 -90
+39 -57
src/http/handle_view_event.rs
··· 22 22 }; 23 23 use crate::storage::event::event_exists; 24 24 use crate::storage::event::event_get; 25 - use crate::storage::event::get_event_rsvp_counts; 26 25 use crate::storage::event::get_event_rsvps_with_validation; 26 + use crate::storage::event::get_going_rsvp_count; 27 + use crate::storage::event::get_recent_going_rsvps; 27 28 use crate::storage::event::get_user_rsvp_status_and_validation; 28 29 use crate::storage::event::rsvp_get_by_event_and_did; 29 30 use crate::storage::identity_profile::handle_for_did; ··· 54 55 NSID.to_string() 55 56 } 56 57 57 - /// Helper function to fetch the organizer's handle (which contains their time zone) 58 - /// This is used to implement the time zone selection logic. 59 - async fn fetch_organizer_handle(pool: &StoragePool, did: &str) -> Option<IdentityProfile> { 60 - match handle_for_did(pool, did).await { 61 - Ok(handle) => Some(handle), 62 - Err(err) => { 63 - tracing::warn!("Failed to fetch organizer handle: {}", err); 64 - None 65 - } 66 - } 67 - } 68 - 69 58 /// Helper function to extract metadata from an acceptance record 70 59 fn extract_acceptance_metadata(acceptance_record: serde_json::Value) -> Option<Vec<(String, String)>> { 71 60 match serde_json::from_value::<crate::atproto::lexicon::acceptance::Acceptance>( ··· 91 80 } 92 81 93 82 /// Helper function to fetch recent going RSVPs with handles 94 - /// Returns up to 3 most recent RSVPs with status "going" 83 + /// Returns up to 3 most recent RSVPs with status "going", ordered by updated_at DESC 95 84 async fn fetch_recent_going_rsvps( 96 85 pool: &StoragePool, 97 86 aturi: &str, 98 87 ) -> Vec<RsvpDisplay> { 99 - // Get recent going RSVPs (limited to 3 for display) 100 - let rsvps = get_event_rsvps_with_validation(pool, aturi, Some("going")) 88 + // Get recent going RSVPs (limited to 3, ordered by updated_at DESC) 89 + let rsvps = get_recent_going_rsvps(pool, aturi, 3) 101 90 .await 102 91 .unwrap_or_default(); 103 92 104 - // Extract DIDs for batch lookup (take first 3) 105 - let dids: Vec<String> = rsvps.iter().take(3).map(|(did, _, _)| did.clone()).collect(); 93 + // Extract DIDs for batch lookup 94 + let dids: Vec<String> = rsvps.iter().map(|(did, _, _)| did.clone()).collect(); 106 95 107 96 // Perform batch lookup for handles 108 - let handle_profiles = handles_by_did(pool, dids.clone()) 97 + let handle_profiles = handles_by_did(pool, dids) 109 98 .await 110 99 .unwrap_or_default(); 111 100 ··· 410 399 411 400 let event_result = match &event_get_result { 412 401 Ok(event) => { 413 - let organizer_handle = { 414 - if ctx 415 - .current_handle 416 - .clone() 417 - .is_some_and(|h| h.did == event.did) 418 - { 419 - ctx.current_handle.clone() 420 - } else { 421 - fetch_organizer_handle(&ctx.web_context.pool, &event.did).await 422 - } 402 + // Use current user's handle if viewing their own event (for timezone), 403 + // otherwise use the organizer's profile (which we already have) 404 + let organizer_handle = if ctx 405 + .current_handle 406 + .as_ref() 407 + .is_some_and(|h| h.did == event.did) 408 + { 409 + ctx.current_handle.as_ref() 410 + } else { 411 + Some(&profile) 423 412 }; 424 413 425 414 let facet_limits = crate::facets::FacetLimits { ··· 431 420 432 421 EventView::try_from(( 433 422 ctx.current_handle.as_ref(), 434 - organizer_handle.as_ref(), 423 + organizer_handle, 435 424 event, 436 425 &facet_limits, 437 426 )) ··· 475 464 let ( 476 465 user_rsvp_result, 477 466 user_email_result, 478 - counts_map, 467 + going_count_result, 479 468 recent_going_rsvps, 469 + organizer_profile_record, 480 470 ) = tokio::join!( 481 471 // Get user's RSVP status if logged in 482 472 async { ··· 503 493 Ok(None) 504 494 } 505 495 }, 506 - // Get counts for all RSVP statuses in a single query 496 + // Get count of "going" RSVPs 497 + get_going_rsvp_count(&ctx.web_context.pool, &aturi), 498 + // Get recent going RSVPs for display 499 + fetch_recent_going_rsvps(&ctx.web_context.pool, &aturi), 500 + // Fetch organizer profile record for avatar display 507 501 async { 508 - get_event_rsvp_counts(&ctx.web_context.pool, vec![aturi.clone()]) 502 + profile_get_by_did(&ctx.web_context.pool, &profile.did) 509 503 .await 510 - .unwrap_or_default() 504 + .ok() 505 + .flatten() 511 506 }, 512 - // Get recent going RSVPs for display 513 - fetch_recent_going_rsvps(&ctx.web_context.pool, &aturi), 514 507 ); 515 508 516 509 // Process results from parallel operations ··· 532 525 } 533 526 }; 534 527 535 - let going_count = counts_map 536 - .get(&(aturi.clone(), "going".to_string())) 537 - .copied() 538 - .unwrap_or(0) as u32; 539 - let interested_count = counts_map 540 - .get(&(aturi.clone(), "interested".to_string())) 541 - .copied() 542 - .unwrap_or(0) as u32; 543 - let notgoing_count = counts_map 544 - .get(&(aturi.clone(), "notgoing".to_string())) 545 - .copied() 546 - .unwrap_or(0) as u32; 528 + let going_count = match going_count_result { 529 + Ok(count) => count as u32, 530 + Err(err) => { 531 + tracing::error!("Error getting going RSVP count: {:?}", err); 532 + 0 533 + } 534 + }; 547 535 548 536 // Fetch acceptance data (depends on user_rsvp_status, so must come after) 549 537 let (pending_acceptance_aturi, pending_acceptance_metadata, validated_acceptance_metadata) = ··· 557 545 ) 558 546 .await; 559 547 560 - // Set counts on event 548 + // Set counts on event (only going_count is used in view_event template) 561 549 let mut event_with_counts = event; 562 550 event_with_counts.count_going = going_count; 563 - event_with_counts.count_interested = interested_count; 564 - event_with_counts.count_notgoing = notgoing_count; 551 + event_with_counts.count_interested = 0; 552 + event_with_counts.count_notgoing = 0; 565 553 566 554 // Check if private content should be displayed 567 555 let private_content = get_private_content_if_authorized( ··· 572 560 user_rsvp_is_validated, 573 561 ) 574 562 .await; 575 - 576 - // Fetch organizer profile record for avatar display 577 - let organizer_profile_record = profile_get_by_did(&ctx.web_context.pool, &profile.did) 578 - .await 579 - .ok() 580 - .flatten(); 581 563 582 564 Ok(( 583 565 StatusCode::OK,
+69
src/storage/event.rs
··· 585 585 Ok(rsvps) 586 586 } 587 587 588 + /// Get recent going RSVPs for an event, ordered by updated_at DESC 589 + /// Returns up to `limit` most recent RSVPs with status "going" 590 + pub async fn get_recent_going_rsvps( 591 + pool: &StoragePool, 592 + event_aturi: &str, 593 + limit: i64, 594 + ) -> Result<Vec<(String, String, Option<chrono::DateTime<chrono::Utc>>)>, StorageError> { 595 + // Validate event_aturi is not empty 596 + if event_aturi.trim().is_empty() { 597 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 598 + "Event URI cannot be empty".into(), 599 + ))); 600 + } 601 + 602 + let mut tx = pool 603 + .begin() 604 + .await 605 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 606 + 607 + let rsvps = sqlx::query_as::<_, (String, String, Option<chrono::DateTime<chrono::Utc>>)>( 608 + "SELECT did, status, validated_at FROM rsvps WHERE event_aturi = $1 AND status = $2 ORDER BY updated_at DESC LIMIT $3", 609 + ) 610 + .bind(event_aturi) 611 + .bind("going") 612 + .bind(limit) 613 + .fetch_all(tx.as_mut()) 614 + .await 615 + .map_err(StorageError::UnableToExecuteQuery)?; 616 + 617 + tx.commit() 618 + .await 619 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 620 + 621 + Ok(rsvps) 622 + } 623 + 624 + /// Get the count of RSVPs with status "going" for an event 625 + pub async fn get_going_rsvp_count( 626 + pool: &StoragePool, 627 + event_aturi: &str, 628 + ) -> Result<i64, StorageError> { 629 + // Validate event_aturi is not empty 630 + if event_aturi.trim().is_empty() { 631 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 632 + "Event URI cannot be empty".into(), 633 + ))); 634 + } 635 + 636 + let mut tx = pool 637 + .begin() 638 + .await 639 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 640 + 641 + let count = sqlx::query_scalar::<_, i64>( 642 + "SELECT COUNT(*) FROM rsvps WHERE event_aturi = $1 AND status = $2", 643 + ) 644 + .bind(event_aturi) 645 + .bind("going") 646 + .fetch_one(tx.as_mut()) 647 + .await 648 + .map_err(StorageError::UnableToExecuteQuery)?; 649 + 650 + tx.commit() 651 + .await 652 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 653 + 654 + Ok(count) 655 + } 656 + 588 657 pub async fn get_user_rsvp( 589 658 pool: &StoragePool, 590 659 event_aturi: &str,
+27 -15
templates/en-us/view_event.common.html
··· 1 1 {% macro verified_check(attendee) -%} 2 - {%- if attendee.status == "going" -%}<span class="icon has-text-success" title="Going"><i class="fas fa-check-circle"></i></span> 3 - {%- elif attendee.status == "interested" -%}<span class="icon has-text-info" title="Interested"><i class="fas fa-star"></i></span> 4 - {%- elif attendee.status == "notgoing" -%}<span class="icon has-text-danger" title="Not Going"><i class="fas fa-times-circle"></i></span>{%- endif -%} 5 - {%- if attendee.verified -%}<span class="icon has-text-link" title="The event organizer has signed this RSVP."><i class="fa fa-check-circle"></i></span>{%- endif -%} 2 + {%- if attendee.verified -%} 3 + <span class="icon"><i class="fa fa-certificate"></i></span> 4 + {%- else -%} 5 + {%- if attendee.status == "going" -%} 6 + <span class="icon"><i class="fas fa-circle-check"></i></span> 7 + {%- elif attendee.status == "interested" -%} 8 + <span class="icon"><i class="fas fa-circle-question"></i></span> 9 + {%- elif attendee.status == "notgoing" -%} 10 + <span class="icon"><i class="fas fa-circle-xmark"></i></span> 11 + {%- endif -%} 12 + {%- endif -%} 6 13 {%- endmacro %} 7 14 <style> 8 15 /* ============================================ ··· 314 321 <!-- Attendance Summary --> 315 322 <div class="box"> 316 323 {% set going_count = event.count_going | default(0) %} 324 + {% set rsvp_count = recent_going_rsvps|length %} 317 325 {% if going_count == 0 %} 318 - <p class="has-text-grey">No one is going yet.</p> 319 - {% elif recent_going_rsvps and recent_going_rsvps|length > 0 %} 326 + <p class="has-text-grey">No one is going to the event yet. <a href="{{ base }}/{{ handle_slug }}/{{ event_rkey }}/attendees" class="has-text-weight-semibold">View all attendees</a></p> 327 + {% elif rsvp_count > 0 %} 320 328 <p> 321 - {% set rsvp_count = recent_going_rsvps|length %} 322 - {% if rsvp_count == 1 %} 323 - {{ verified_check(recent_going_rsvps[0]) }}<a href="{{ base }}/@{{ recent_going_rsvps[0].handle }}">@{{ recent_going_rsvps[0].handle }}</a> is going. 324 - {% elif rsvp_count == 2 %} 325 - {{ verified_check(recent_going_rsvps[0]) }}<a href="{{ base }}/@{{ recent_going_rsvps[0].handle }}">@{{ recent_going_rsvps[0].handle }}</a> and {{ verified_check(recent_going_rsvps[1]) }}<a href="{{ base }}/@{{ recent_going_rsvps[1].handle }}">@{{ recent_going_rsvps[1].handle }}</a> are going. 326 - {% elif rsvp_count == 3 %} 327 - {{ verified_check(recent_going_rsvps[0]) }}<a href="{{ base }}/@{{ recent_going_rsvps[0].handle }}">@{{ recent_going_rsvps[0].handle }}</a>, {{ verified_check(recent_going_rsvps[1]) }}<a href="{{ base }}/@{{ recent_going_rsvps[1].handle }}">@{{ recent_going_rsvps[1].handle }}</a>, and {{ verified_check(recent_going_rsvps[2]) }}<a href="{{ base }}/@{{ recent_going_rsvps[2].handle }}">@{{ recent_going_rsvps[2].handle }}</a> are going. 329 + {% if going_count == 1 %} 330 + {{ verified_check(recent_going_rsvps[0]) }}<a href="{{ base }}/@{{ recent_going_rsvps[0].handle }}">@{{ recent_going_rsvps[0].handle }}</a> is going to the event. 331 + {% elif going_count == 2 %} 332 + {{ verified_check(recent_going_rsvps[0]) }}<a href="{{ base }}/@{{ recent_going_rsvps[0].handle }}">@{{ recent_going_rsvps[0].handle }}</a> and 333 + {{ verified_check(recent_going_rsvps[1]) }}<a href="{{ base }}/@{{ recent_going_rsvps[1].handle }}">@{{ recent_going_rsvps[1].handle }}</a> are going to the event. 334 + {% elif going_count == 3 %} 335 + {{ verified_check(recent_going_rsvps[0]) }}<a href="{{ base }}/@{{ recent_going_rsvps[0].handle }}">@{{ recent_going_rsvps[0].handle }}</a>, 336 + {{ verified_check(recent_going_rsvps[1]) }}<a href="{{ base }}/@{{ recent_going_rsvps[1].handle }}">@{{ recent_going_rsvps[1].handle }}</a>, and 337 + {{ verified_check(recent_going_rsvps[2]) }}<a href="{{ base }}/@{{ recent_going_rsvps[2].handle }}">@{{ recent_going_rsvps[2].handle }}</a> are going to the event. 328 338 {% else %} 329 339 {% set others_count = going_count - 3 %} 330 - {{ verified_check(recent_going_rsvps[0]) }}<a href="{{ base }}/@{{ recent_going_rsvps[0].handle }}">@{{ recent_going_rsvps[0].handle }}</a>, {{ verified_check(recent_going_rsvps[1]) }}<a href="{{ base }}/@{{ recent_going_rsvps[1].handle }}">@{{ recent_going_rsvps[1].handle }}</a>, {{ verified_check(recent_going_rsvps[2]) }}<a href="{{ base }}/@{{ recent_going_rsvps[2].handle }}">@{{ recent_going_rsvps[2].handle }}</a> and {{ others_count }} {% if others_count == 1 %}other{% else %}others{% endif %} are going. 340 + {{ verified_check(recent_going_rsvps[0]) }}<a href="{{ base }}/@{{ recent_going_rsvps[0].handle }}">@{{ recent_going_rsvps[0].handle }}</a>, 341 + {{ verified_check(recent_going_rsvps[1]) }}<a href="{{ base }}/@{{ recent_going_rsvps[1].handle }}">@{{ recent_going_rsvps[1].handle }}</a>, 342 + {{ verified_check(recent_going_rsvps[2]) }}<a href="{{ base }}/@{{ recent_going_rsvps[2].handle }}">@{{ recent_going_rsvps[2].handle }}</a>, and {{ others_count }} other{% if others_count > 1 %}s{% endif %} are going to the event. 331 343 {% endif %} 332 344 <a href="{{ base }}/{{ handle_slug }}/{{ event_rkey }}/attendees" class="has-text-weight-semibold">View all attendees</a> 333 345 </p> 334 346 {% else %} 335 - <p><strong>{{ going_count }} {% if going_count == 1 %}person{% else %}people{% endif %}</strong> {% if going_count == 1 %}is{% else %}are{% endif %} going. <a href="{{ base }}/{{ handle_slug }}/{{ event_rkey }}/attendees" class="has-text-weight-semibold">View all attendees</a></p> 347 + <p><strong>{{ going_count }} {% if going_count == 1 %}person{% else %}people{% endif %}</strong> {% if going_count == 1 %}is{% else %}are{% endif %} going to the event. <a href="{{ base }}/{{ handle_slug }}/{{ event_rkey }}/attendees" class="has-text-weight-semibold">View all attendees</a></p> 336 348 {% endif %} 337 349 </div> 338 350
+14 -18
templates/en-us/view_event_attendees.common.html
··· 1 + {% macro verified_check(attendee) -%} 2 + {%- if attendee.verified -%} 3 + <span class="icon"><i class="fa fa-certificate"></i></span> 4 + {%- else -%} 5 + {%- if attendee.status == "going" -%} 6 + <span class="icon"><i class="fas fa-circle-check"></i></span> 7 + {%- elif attendee.status == "interested" -%} 8 + <span class="icon"><i class="fas fa-circle-question"></i></span> 9 + {%- elif attendee.status == "notgoing" -%} 10 + <span class="icon"><i class="fas fa-circle-xmark"></i></span> 11 + {%- endif -%} 12 + {%- endif -%} 13 + {%- endmacro %} 1 14 <section class="section"> 2 15 <div class="container"> 3 16 <div class="content"> ··· 10 23 <div class="grid is-col-min-12 has-text-centered"> 11 24 {% for attendee in attendees %} 12 25 <span class="cell"> 13 - {% if attendee.status == "going" %} 14 - <span class="icon has-text-success" title="Going"> 15 - <i class="fas fa-check-circle"></i> 16 - </span> 17 - {% elif attendee.status == "interested" %} 18 - <span class="icon has-text-info" title="Interested"> 19 - <i class="fas fa-star"></i> 20 - </span> 21 - {% elif attendee.status == "notgoing" %} 22 - <span class="icon has-text-danger" title="Not Going"> 23 - <i class="fas fa-times-circle"></i> 24 - </span> 25 - {% endif %} 26 - {% if attendee.verified %} 27 - <span class="icon has-text-link" title="The event organizer has signed this RSVP."> 28 - <i class="fa fa-check-circle"></i> 29 - </span> 30 - {% endif %} 26 + {{ verified_check(attendee) }} 31 27 <a href="{ base }}/{{ attendee.did }}"> 32 28 {% if attendee.handle %} 33 29 @{{ attendee.handle }}