i18n+filtering fork - fluent-templates v2
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix : remove categories derived from txt analysis.

+1405 -92
+897
docs/FILTERING_PHASE4_I18N_PLAN.md
··· 1 + # Event Filtering System - Phase 4: I18n Integration Plan 2 + 3 + ## Overview 4 + 5 + Phase 4 integrates the existing event filtering system with the i18n infrastructure, enabling locale-aware facet calculation, template rendering with dynamic translation functions, and HTMX-aware language propagation. 6 + 7 + **Status**: Ready for Implementation ✅ 8 + **Prerequisites**: Phase 1-3 Complete, I18n Infrastructure Complete 9 + **Estimated Effort**: 2-3 days 10 + 11 + --- 12 + 13 + ## Current State Analysis 14 + 15 + ### ✅ Infrastructure Ready 16 + 17 + #### I18n System 18 + - **fluent-templates static loader**: Fully implemented and working 19 + - **Template functions**: `t()`, `tg()`, `current_locale()`, `has_locale()` already available 20 + - **HTMX middleware**: Language detection with proper priority order implemented 21 + - **Gender support**: French Canadian gender variants working 22 + 23 + #### Filtering System 24 + - **Phase 1**: Core filtering, query builder, facets, hydration complete 25 + - **Phase 2**: Facet calculation and event hydration complete 26 + - **Phase 3**: Redis cache integration complete 27 + - **Templates**: Language-specific templates exist (`.en-us.html`, `.fr-ca.html`) 28 + 29 + #### Translation Keys 30 + - **Basic filter keys**: Most UI strings already translated in `i18n/*/ui.ftl` 31 + - **Missing**: Facet-specific translation keys for dynamic content 32 + 33 + ### 🔧 Implementation Needed 34 + 35 + 1. **Facet Translation Keys**: Add missing keys for category/date range facets 36 + 2. **Locale-Aware Facet Calculation**: Pass locale context to facet calculators 37 + 3. **Template Migration**: Update templates to use i18n functions instead of pre-rendered text 38 + 4. **Service Integration**: Connect filtering service with locale from middleware 39 + 5. **HTMX Language Headers**: Ensure facet updates propagate language correctly 40 + 41 + --- 42 + 43 + ## Implementation Tasks 44 + 45 + ### Task 1: Add Missing Translation Keys 46 + 47 + #### 1.2 Date Range Facet Keys 48 + Add date range translation keys: 49 + 50 + **File**: `/i18n/en-us/ui.ftl` 51 + ```fluent 52 + # Date range facets 53 + date_range.today = Today 54 + date_range.this_week = This Week 55 + date_range.this_month = This Month 56 + date_range.next_week = Next Week 57 + date_range.next_month = Next Month 58 + ``` 59 + 60 + **File**: `/i18n/fr-ca/ui.ftl` 61 + ```fluent 62 + # Facettes de plage de dates 63 + date_range.today = Aujourd'hui 64 + date_range.this_week = Cette semaine 65 + date_range.this_month = Ce mois 66 + date_range.next_week = La semaine prochaine 67 + date_range.next_month = Le mois prochain 68 + ``` 69 + 70 + #### 1.3 Additional Facet UI Keys 71 + Extend existing filter keys for facet display: 72 + 73 + **File**: `/i18n/en-us/ui.ftl` 74 + ```fluent 75 + # Facet labels and counts 76 + filter-categories-label = Categories 77 + filter-creators-label = Event Creators 78 + filter-date-ranges-label = Date Ranges 79 + filter-facet-count = { $count -> 80 + [one] ({ $count } event) 81 + *[other] ({ $count } events) 82 + } 83 + filter-clear-facet = Clear { $facet } 84 + filter-show-more-facets = Show more... 85 + filter-show-less-facets = Show less 86 + ``` 87 + 88 + **File**: `/i18n/fr-ca/ui.ftl` 89 + ```fluent 90 + # Étiquettes et comptes de facettes 91 + filter-categories-label = Catégories 92 + filter-creators-label = Créateurs d'événements 93 + filter-date-ranges-label = Plages de dates 94 + filter-facet-count = { $count -> 95 + [one] ({ $count } événement) 96 + *[other] ({ $count } événements) 97 + } 98 + filter-clear-facet = Effacer { $facet } 99 + filter-show-more-facets = Voir plus... 100 + filter-show-less-facets = Voir moins 101 + ``` 102 + 103 + ### Task 2: Update Facet Calculation Service 104 + 105 + #### 2.1 Add Locale Parameter to FacetCalculator 106 + **File**: `/src/filtering/facets.rs` 107 + 108 + ```rust 109 + impl FacetCalculator { 110 + /// Calculate all facets for the given filter criteria with locale support 111 + #[instrument(skip(self, criteria))] 112 + pub async fn calculate_facets_with_locale( 113 + &self, 114 + criteria: &EventFilterCriteria, 115 + locale: &LanguageIdentifier, 116 + ) -> Result<EventFacets, FilterError> { 117 + let mut facets = EventFacets::default(); 118 + 119 + // Calculate total count 120 + facets.total_count = self.calculate_total_count(criteria).await?; 121 + 122 + // Calculate category facets with i18n support 123 + facets.categories = self.calculate_category_facets_with_locale(criteria, locale).await?; 124 + 125 + // Calculate creator facets 126 + facets.creators = self.calculate_creator_facets(criteria).await?; 127 + 128 + // Calculate date range facets with i18n support 129 + facets.date_ranges = self.calculate_date_range_facets_with_locale(criteria, locale).await?; 130 + 131 + Ok(facets) 132 + } 133 + 134 + /// Calculate category facets with locale-aware translations 135 + async fn calculate_category_facets_with_locale( 136 + &self, 137 + criteria: &EventFilterCriteria, 138 + locale: &LanguageIdentifier, 139 + ) -> Result<Vec<FacetValue>, FilterError> { 140 + // ...existing query logic... 141 + 142 + let mut facets = Vec::new(); 143 + for row in rows { 144 + let category: String = row.try_get("category")?; 145 + let count: i64 = row.try_get("count")?; 146 + 147 + let i18n_key = Self::generate_category_i18n_key(&category); 148 + 149 + facets.push(FacetValue { 150 + i18n_key: Some(i18n_key), 151 + value: category, 152 + count, 153 + // Add locale-specific display name if translation exists 154 + display_name: Some(self.get_translated_facet_name(&i18n_key, locale)), 155 + }); 156 + } 157 + 158 + Ok(facets) 159 + } 160 + 161 + /// Get translated facet name with fallback to original value 162 + fn get_translated_facet_name(&self, i18n_key: &str, locale: &LanguageIdentifier) -> String { 163 + use crate::i18n::fluent_loader::LOCALES; 164 + 165 + let translated = LOCALES.lookup(locale, i18n_key); 166 + 167 + // If translation returns the key itself, it means no translation found 168 + if translated == i18n_key { 169 + // Extract readable name from key: "category.technology_and_innovation" -> "Technology And Innovation" 170 + i18n_key 171 + .split('.') 172 + .last() 173 + .unwrap_or(i18n_key) 174 + .replace('_', " ") 175 + .split_whitespace() 176 + .map(|word| { 177 + let mut chars = word.chars(); 178 + match chars.next() { 179 + None => String::new(), 180 + Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), 181 + } 182 + }) 183 + .collect::<Vec<_>>() 184 + .join(" ") 185 + } else { 186 + translated 187 + } 188 + } 189 + } 190 + ``` 191 + 192 + #### 2.2 Update FacetValue Structure 193 + **File**: `/src/filtering/mod.rs` 194 + 195 + ```rust 196 + /// A single facet value with count and optional i18n support 197 + #[derive(Debug, Clone, Serialize, Deserialize)] 198 + pub struct FacetValue { 199 + /// The actual filter value 200 + pub value: String, 201 + /// Number of events matching this facet 202 + pub count: i64, 203 + /// Optional i18n key for translation 204 + pub i18n_key: Option<String>, 205 + /// Pre-calculated display name (locale-specific) 206 + pub display_name: Option<String>, 207 + } 208 + ``` 209 + 210 + ### Task 3: Update FilteringService Integration 211 + 212 + #### 3.1 Pass Locale to Service 213 + **File**: `/src/filtering/service.rs` 214 + 215 + ```rust 216 + impl FilteringService { 217 + /// Filter events with locale-aware facets 218 + #[instrument(skip(self, criteria))] 219 + pub async fn filter_events_with_locale( 220 + &self, 221 + criteria: &EventFilterCriteria, 222 + locale: &LanguageIdentifier, 223 + options: FilterOptions, 224 + ) -> Result<FilterResults, FilterError> { 225 + // Generate cache key including locale 226 + let cache_key = format!("filter:{}:{}", criteria.cache_hash(), locale.to_string()); 227 + 228 + // Try cache first (if enabled) 229 + if let Some(ref cache) = self.cache { 230 + if let Ok(Some(cached_results)) = cache.get::<FilterResults>(&cache_key).await { 231 + debug!("Cache hit for filter query with locale {}", locale); 232 + return Ok(cached_results); 233 + } 234 + } 235 + 236 + // Execute query 237 + let events = self.query_builder 238 + .filter_events(criteria, &self.pool, &options) 239 + .await?; 240 + 241 + // Calculate locale-aware facets 242 + let facets = if options.include_facets { 243 + Some(self.facet_calculator.calculate_facets_with_locale(criteria, locale).await?) 244 + } else { 245 + None 246 + }; 247 + 248 + // Hydrate events if requested 249 + let hydrated_events = if options.include_hydration { 250 + self.hydrator.hydrate_events(events, &options.hydration_options).await? 251 + } else { 252 + events.into_iter().map(EventView::from_event).collect() 253 + }; 254 + 255 + let results = FilterResults { 256 + events: hydrated_events, 257 + facets, 258 + total_count: facets.as_ref().map(|f| f.total_count).unwrap_or(0), 259 + }; 260 + 261 + // Cache results (if enabled) 262 + if let Some(ref cache) = self.cache { 263 + let _ = cache.set(&cache_key, &results, self.config.cache_ttl).await; 264 + } 265 + 266 + Ok(results) 267 + } 268 + } 269 + ``` 270 + 271 + ### Task 4: Update HTTP Handlers 272 + 273 + #### 4.1 Extract Locale from Middleware 274 + **File**: `/src/http/handle_filter_events.rs` 275 + 276 + ```rust 277 + use crate::http::middleware_i18n::Language; 278 + 279 + /// Main filtering endpoint with i18n support 280 + #[instrument(skip(ctx, filtering_service))] 281 + pub async fn handle_filter_events( 282 + Extension(ctx): Extension<WebContext>, 283 + Extension(filtering_service): Extension<Arc<FilteringService>>, 284 + language: Language, 285 + filter_params: FilterQueryParams, 286 + headers: HeaderMap, 287 + ) -> Result<impl IntoResponse, WebError> { 288 + let is_htmx = headers.contains_key("hx-request"); 289 + let criteria = EventFilterCriteria::from_query_params(&filter_params)?; 290 + 291 + // Include locale in filtering options 292 + let options = FilterOptions { 293 + include_facets: true, 294 + include_hydration: true, 295 + hydration_options: HydrationOptions::default(), 296 + }; 297 + 298 + // Use locale-aware filtering 299 + let results = filtering_service 300 + .filter_events_with_locale(&criteria, &language.0, options) 301 + .await?; 302 + 303 + // Template selection with locale 304 + let template_name = if is_htmx { 305 + format!("filter_events_results.{}.incl.html", language.0) 306 + } else { 307 + format!("filter_events.{}.html", language.0) 308 + }; 309 + 310 + // Enhanced context for templates 311 + let template_context = template_context! { 312 + events => results.events, 313 + facets => results.facets, 314 + filter_criteria => criteria, 315 + total_count => results.total_count, 316 + current_locale => language.0.to_string(), 317 + }; 318 + 319 + Ok(RenderHtml(template_name, ctx.engine, template_context)) 320 + } 321 + ``` 322 + 323 + ### Task 5: Update Templates to Use I18n Functions 324 + 325 + #### 5.1 Update Filter Form Template 326 + **File**: `/templates/filter_events.en-us.common.html` → **Migrate to Single Template** 327 + 328 + Create new unified template: `/templates/filter_events.common.html` 329 + 330 + ```html 331 + <div class="section"> 332 + <div class="container"> 333 + <div class="columns"> 334 + <!-- Filter Sidebar --> 335 + <div class="column is-3"> 336 + <div class="box"> 337 + <h2 class="title is-5">{{ t("filter-title") }}</h2> 338 + 339 + <!-- Search Form --> 340 + <form id="filter-form" 341 + hx-get="/events" 342 + hx-target="#events-results" 343 + hx-trigger="submit, change from:input, keyup[keyCode==13] from:input" 344 + hx-push-url="true" 345 + hx-headers='{"HX-Current-Language": "{{ current_locale() }}"}'> 346 + 347 + <!-- Text Search --> 348 + <div class="field"> 349 + <label class="label">{{ t("filter-search-label") }}</label> 350 + <div class="control"> 351 + <input class="input" 352 + type="text" 353 + name="q" 354 + value="{{ filter_criteria.text_search | default('') }}" 355 + placeholder="{{ t('filter-search-placeholder') }}"> 356 + </div> 357 + </div> 358 + 359 + <!-- Date Range --> 360 + <div class="field"> 361 + <label class="label">{{ t("filter-date-range-label") }}</label> 362 + <div class="field-body"> 363 + <div class="field"> 364 + <div class="control"> 365 + <input class="input" 366 + type="date" 367 + name="start_date" 368 + value="{{ filter_criteria.start_date | default('') }}"> 369 + </div> 370 + </div> 371 + <div class="field"> 372 + <div class="control"> 373 + <input class="input" 374 + type="date" 375 + name="end_date" 376 + value="{{ filter_criteria.end_date | default('') }}"> 377 + </div> 378 + </div> 379 + </div> 380 + </div> 381 + 382 + <!-- Sort Options --> 383 + <div class="field"> 384 + <label class="label">{{ t("filter-sort-label") }}</label> 385 + <div class="control"> 386 + <div class="select is-fullwidth"> 387 + <select name="sort"> 388 + <option value="date_asc" {% if filter_criteria.sort_by == "date_asc" %}selected{% endif %}> 389 + {{ t("filter-sort-date-asc") }} 390 + </option> 391 + <option value="date_desc" {% if filter_criteria.sort_by == "date_desc" %}selected{% endif %}> 392 + {{ t("filter-sort-date-desc") }} 393 + </option> 394 + <option value="created_asc" {% if filter_criteria.sort_by == "created_asc" %}selected{% endif %}> 395 + {{ t("filter-sort-created-asc") }} 396 + </option> 397 + <option value="created_desc" {% if filter_criteria.sort_by == "created_desc" %}selected{% endif %}> 398 + {{ t("filter-sort-created-desc") }} 399 + </option> 400 + <option value="relevance" {% if filter_criteria.sort_by == "relevance" %}selected{% endif %}> 401 + {{ t("filter-sort-relevance") }} 402 + </option> 403 + </select> 404 + </div> 405 + </div> 406 + </div> 407 + 408 + <!-- Clear Button --> 409 + <div class="field"> 410 + <div class="control"> 411 + <a href="/events" class="button is-light is-fullwidth"> 412 + {{ t("filter-clear-all") }} 413 + </a> 414 + </div> 415 + </div> 416 + </form> 417 + </div> 418 + 419 + <!-- Dynamic Facets --> 420 + {% if facets %} 421 + <div class="box"> 422 + <h3 class="title is-6">{{ t("filter-facets-title") }}</h3> 423 + 424 + <!-- Category Facets --> 425 + {% if facets.categories %} 426 + <div class="field"> 427 + <label class="label">{{ t("filter-categories-label") }}</label> 428 + {% for category in facets.categories %} 429 + <div class="control"> 430 + <label class="checkbox"> 431 + <input type="checkbox" 432 + name="categories" 433 + value="{{ category.value }}" 434 + {% if category.value in filter_criteria.categories %}checked{% endif %} 435 + hx-get="/events" 436 + hx-target="#events-results" 437 + hx-include="closest form" 438 + hx-headers='{"HX-Current-Language": "{{ current_locale() }}"}'> 439 + 440 + <!-- Use display_name if available, otherwise translate i18n_key --> 441 + {% if category.display_name %} 442 + {{ category.display_name }} 443 + {% elif category.i18n_key %} 444 + {{ t(category.i18n_key) }} 445 + {% else %} 446 + {{ category.value }} 447 + {% endif %} 448 + 449 + {{ t("filter-facet-count", count=category.count) }} 450 + </label> 451 + </div> 452 + {% endfor %} 453 + </div> 454 + {% endif %} 455 + 456 + <!-- Date Range Facets --> 457 + {% if facets.date_ranges %} 458 + <div class="field"> 459 + <label class="label">{{ t("filter-date-ranges-label") }}</label> 460 + {% for date_range in facets.date_ranges %} 461 + <div class="control"> 462 + <label class="checkbox"> 463 + <input type="checkbox" 464 + name="date_ranges" 465 + value="{{ date_range.value }}" 466 + {% if date_range.value in filter_criteria.date_ranges %}checked{% endif %} 467 + hx-get="/events" 468 + hx-target="#events-results" 469 + hx-include="closest form" 470 + hx-headers='{"HX-Current-Language": "{{ current_locale() }}"}'> 471 + 472 + {% if date_range.i18n_key %} 473 + {{ t(date_range.i18n_key) }} 474 + {% else %} 475 + {{ date_range.value }} 476 + {% endif %} 477 + 478 + {{ t("filter-facet-count", count=date_range.count) }} 479 + </label> 480 + </div> 481 + {% endfor %} 482 + </div> 483 + {% endif %} 484 + 485 + <!-- Creator Facets --> 486 + {% if facets.creators %} 487 + <div class="field"> 488 + <label class="label">{{ t("filter-creators-label") }}</label> 489 + {% for creator in facets.creators %} 490 + <div class="control"> 491 + <label class="checkbox"> 492 + <input type="checkbox" 493 + name="creators" 494 + value="{{ creator.value }}" 495 + {% if creator.value in filter_criteria.creators %}checked{% endif %} 496 + hx-get="/events" 497 + hx-target="#events-results" 498 + hx-include="closest form" 499 + hx-headers='{"HX-Current-Language": "{{ current_locale() }}"}'> 500 + {{ creator.value }} {{ t("filter-facet-count", count=creator.count) }} 501 + </label> 502 + </div> 503 + {% endfor %} 504 + </div> 505 + {% endif %} 506 + </div> 507 + {% endif %} 508 + </div> 509 + 510 + <!-- Events Results --> 511 + <div class="column is-9"> 512 + <div id="events-results"> 513 + {% include 'filter_events_results.incl.html' %} 514 + </div> 515 + </div> 516 + </div> 517 + </div> 518 + </div> 519 + ``` 520 + 521 + #### 5.2 Update Main Filter Pages 522 + Update both main filter pages to use the unified common template: 523 + 524 + **File**: `/templates/filter_events.en-us.html` 525 + ```html 526 + {% extends "base." + current_locale() + ".html" %} 527 + {% block title %}{{ t("filter-events-title") }}{% endblock %} 528 + {% block head %} 529 + <meta name="description" content="{{ t("filter-events-description") }}"> 530 + <meta property="og:title" content="{{ t("filter-events-title") }}"> 531 + <meta property="og:description" content="{{ t("filter-events-description") }}"> 532 + <meta property="og:site_name" content="{{ t("site-name") }}" /> 533 + <meta property="og:type" content="website" /> 534 + <meta property="og:url" content="{{ external_base }}/events" /> 535 + <script src="https://unpkg.com/htmx.org@1.9.12"></script> 536 + {% endblock %} 537 + {% block content %} 538 + {% include 'filter_events.common.html' %} 539 + {% endblock %} 540 + ``` 541 + 542 + **File**: `/templates/filter_events.fr-ca.html` 543 + ```html 544 + {% extends "base." + current_locale() + ".html" %} 545 + {% block title %}{{ t("filter-events-title") }}{% endblock %} 546 + {% block head %} 547 + <meta name="description" content="{{ t("filter-events-description") }}"> 548 + <meta property="og:title" content="{{ t("filter-events-title") }}"> 549 + <meta property="og:description" content="{{ t("filter-events-description") }}"> 550 + <meta property="og:site_name" content="{{ t("site-name") }}" /> 551 + <meta property="og:type" content="website" /> 552 + <meta property="og:url" content="{{ external_base }}/events" /> 553 + <script src="https://unpkg.com/htmx.org@1.9.12"></script> 554 + {% endblock %} 555 + {% block content %} 556 + {% include 'filter_events.common.html' %} 557 + {% endblock %} 558 + ``` 559 + 560 + #### 5.3 Update HTMX Results Template 561 + Create unified results template: `/templates/filter_events_results.incl.html` 562 + 563 + ```html 564 + <!-- Results Header --> 565 + <div class="level"> 566 + <div class="level-left"> 567 + <div class="level-item"> 568 + <h1 class="title is-4"> 569 + {% if filter_criteria.search_term %} 570 + {{ t("filter-search-results-for", term=filter_criteria.search_term) }} 571 + {% else %} 572 + {{ t("filter-all-events") }} 573 + {% endif %} 574 + </h1> 575 + </div> 576 + </div> 577 + <div class="level-right"> 578 + <div class="level-item"> 579 + <p class="subtitle is-6"> 580 + {{ t("filter-results-count", count=total_count) }} 581 + </p> 582 + </div> 583 + </div> 584 + </div> 585 + 586 + <!-- Events List --> 587 + {% if events %} 588 + <div class="columns is-multiline"> 589 + {% for event in events %} 590 + <div class="column is-half"> 591 + <div class="card"> 592 + <div class="card-content"> 593 + <div class="media"> 594 + <div class="media-content"> 595 + <p class="title is-5"> 596 + <a href="/events/{{ event.rkey }}">{{ event.name }}</a> 597 + </p> 598 + <p class="subtitle is-6"> 599 + {% if event.creator_handle %} 600 + {{ t("event-by-creator", creator=event.creator_handle) }} 601 + {% endif %} 602 + </p> 603 + </div> 604 + </div> 605 + 606 + <div class="content"> 607 + {% if event.description %} 608 + <p>{{ event.description | truncate(150) }}</p> 609 + {% endif %} 610 + 611 + <div class="tags"> 612 + {% if event.start_time %} 613 + <span class="tag is-info"> 614 + <i class="fas fa-calendar"></i> 615 + {{ event.start_time | date("Y-m-d H:i") }} 616 + </span> 617 + {% endif %} 618 + 619 + {% if event.location %} 620 + <span class="tag is-success"> 621 + <i class="fas fa-map-marker-alt"></i> 622 + {{ event.location }} 623 + </span> 624 + {% endif %} 625 + 626 + {% if event.rsvp_count %} 627 + <span class="tag is-warning"> 628 + <i class="fas fa-users"></i> 629 + {{ t("event-rsvp-count", count=event.rsvp_count) }} 630 + </span> 631 + {% endif %} 632 + </div> 633 + </div> 634 + </div> 635 + </div> 636 + </div> 637 + {% endfor %} 638 + </div> 639 + 640 + <!-- Pagination --> 641 + {% if has_more_pages %} 642 + <nav class="pagination is-centered" role="navigation"> 643 + <button class="pagination-previous button" 644 + hx-get="/events?page={{ current_page - 1 }}" 645 + hx-target="#events-results" 646 + hx-include="closest form" 647 + hx-headers='{"HX-Current-Language": "{{ current_locale() }}"}'> 648 + {{ t("pagination-previous") }} 649 + </button> 650 + <button class="pagination-next button" 651 + hx-get="/events?page={{ current_page + 1 }}" 652 + hx-target="#events-results" 653 + hx-include="closest form" 654 + hx-headers='{"HX-Current-Language": "{{ current_locale() }}"}'> 655 + {{ t("pagination-next") }} 656 + </button> 657 + </nav> 658 + {% endif %} 659 + {% else %} 660 + <div class="notification is-info"> 661 + <p>{{ t("filter-no-results") }}</p> 662 + <p>{{ t("filter-try-different-criteria") }}</p> 663 + </div> 664 + {% endif %} 665 + ``` 666 + 667 + ### Task 6: Update Route Registration 668 + 669 + #### 6.1 Ensure I18n Middleware Integration 670 + **File**: `/src/http/mod.rs` (or main router setup) 671 + 672 + ```rust 673 + // Ensure filtering routes have i18n middleware 674 + pub fn create_filtering_routes() -> Router<WebContext> { 675 + Router::new() 676 + .route("/events", get(handle_filter_events)) 677 + .route("/events/facets", get(handle_filter_facets)) 678 + .route("/events/suggestions", get(handle_filter_suggestions)) 679 + // Middleware stack includes i18n detection 680 + .layer(from_fn(middleware_i18n::detect_language)) 681 + .layer(from_fn(middleware_filter::extract_filter_params)) 682 + } 683 + ``` 684 + 685 + ### Task 7: Testing Integration 686 + 687 + #### 7.1 Update Existing Tests 688 + **File**: `/src/filtering/facets.rs` - Add i18n tests 689 + 690 + ```rust 691 + #[cfg(test)] 692 + mod tests { 693 + use super::*; 694 + use unic_langid::langid; 695 + 696 + #[test] 697 + fn test_generate_category_i18n_key() { 698 + assert_eq!( 699 + FacetCalculator::generate_category_i18n_key("Technology & Innovation"), 700 + "category.technology_and_innovation" 701 + ); 702 + 703 + assert_eq!( 704 + FacetCalculator::generate_category_i18n_key("Arts & Culture"), 705 + "category.arts_and_culture" 706 + ); 707 + } 708 + 709 + #[test] 710 + fn test_get_translated_facet_name() { 711 + let calculator = FacetCalculator::new(/* mock pool */); 712 + let locale = langid!("en-US"); 713 + 714 + // Test with existing translation key 715 + let translated = calculator.get_translated_facet_name( 716 + "category.technology_and_innovation", 717 + &locale 718 + ); 719 + 720 + // Should return translated text if key exists, otherwise formatted fallback 721 + assert!(!translated.is_empty()); 722 + } 723 + 724 + #[sqlx::test] 725 + async fn test_facet_calculation_with_locale() { 726 + // Integration test with real database 727 + let pool = setup_test_db().await; 728 + let calculator = FacetCalculator::new(pool); 729 + let criteria = EventFilterCriteria::default(); 730 + let locale = langid!("en-US"); 731 + 732 + let facets = calculator 733 + .calculate_facets_with_locale(&criteria, &locale) 734 + .await 735 + .unwrap(); 736 + 737 + // Verify facets have proper i18n keys and display names 738 + for category in &facets.categories { 739 + assert!(category.i18n_key.is_some()); 740 + assert!(category.display_name.is_some()); 741 + } 742 + } 743 + } 744 + ``` 745 + 746 + #### 7.2 Add I18n Template Tests 747 + **File**: `/tests/integration/filter_i18n_test.rs` 748 + 749 + ```rust 750 + #[tokio::test] 751 + async fn test_filter_page_renders_with_locale() { 752 + let app = create_test_app().await; 753 + 754 + // Test English 755 + let response = app 756 + .oneshot( 757 + Request::builder() 758 + .uri("/events") 759 + .header("Accept-Language", "en-US,en;q=0.9") 760 + .body(Body::empty()) 761 + .unwrap(), 762 + ) 763 + .await 764 + .unwrap(); 765 + 766 + assert_eq!(response.status(), StatusCode::OK); 767 + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); 768 + let html = String::from_utf8(body.to_vec()).unwrap(); 769 + 770 + // Verify English translations are used 771 + assert!(html.contains("Filter Options")); 772 + assert!(html.contains("Categories")); 773 + 774 + // Test French 775 + let response = app 776 + .oneshot( 777 + Request::builder() 778 + .uri("/events") 779 + .header("Accept-Language", "fr-CA,fr;q=0.9") 780 + .body(Body::empty()) 781 + .unwrap(), 782 + ) 783 + .await 784 + .unwrap(); 785 + 786 + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); 787 + let html = String::from_utf8(body.to_vec()).unwrap(); 788 + 789 + // Verify French translations are used 790 + assert!(html.contains("Options de filtrage")); 791 + assert!(html.contains("Catégories")); 792 + } 793 + 794 + #[tokio::test] 795 + async fn test_htmx_facet_updates_preserve_language() { 796 + let app = create_test_app().await; 797 + 798 + let response = app 799 + .oneshot( 800 + Request::builder() 801 + .uri("/events") 802 + .method("GET") 803 + .header("HX-Request", "true") 804 + .header("HX-Current-Language", "fr-ca") 805 + .body(Body::empty()) 806 + .unwrap(), 807 + ) 808 + .await 809 + .unwrap(); 810 + 811 + assert_eq!(response.status(), StatusCode::OK); 812 + 813 + // Verify HTMX response includes proper language context 814 + let headers = response.headers(); 815 + assert!(headers.contains_key("HX-Language")); 816 + } 817 + ``` 818 + 819 + --- 820 + 821 + ## Implementation Order 822 + 823 + ### Phase 4A: Foundation (Day 1) 824 + 1. **Add translation keys** to both language files 825 + 2. **Update FacetValue structure** with display_name field 826 + 3. **Extend FacetCalculator** with locale-aware methods 827 + 828 + ### Phase 4B: Service Integration (Day 1-2) 829 + 1. **Update FilteringService** to accept locale parameter 830 + 2. **Modify HTTP handlers** to extract and use locale 831 + 3. **Update cache keys** to include locale for proper separation 832 + 833 + ### Phase 4C: Template Migration (Day 2) 834 + 1. **Create unified templates** using i18n functions 835 + 2. **Remove language-specific .common.html files** 836 + 3. **Update HTMX result templates** with proper language headers 837 + 838 + ### Phase 4D: Testing & Validation (Day 2-3) 839 + 1. **Run existing test suite** to ensure no regressions 840 + 2. **Add i18n-specific tests** for facet calculation and template rendering 841 + 3. **Manual testing** of language switching and HTMX updates 842 + 4. **Performance validation** with locale-aware caching 843 + 844 + --- 845 + 846 + ## Expected Outcomes 847 + 848 + ### ✅ Features Delivered 849 + 850 + 1. **Locale-Aware Facets**: Category and date range facets display in user's language 851 + 2. **Dynamic Template Functions**: All filter templates use `t()` functions instead of hardcoded text 852 + 3. **HTMX Language Propagation**: Partial updates maintain language context 853 + 4. **Intelligent Fallbacks**: Graceful degradation when translations missing 854 + 5. **Cache Efficiency**: Locale-specific caching without excessive memory usage 855 + 856 + ### 🚀 Performance Benefits 857 + 858 + - **Zero Runtime Translation Overhead**: fluent-templates static loading 859 + - **Intelligent Caching**: Locale-aware cache keys prevent cross-language contamination 860 + - **Efficient Facet Calculation**: Pre-computed display names reduce template complexity 861 + - **HTMX Optimization**: Language headers minimize full page reloads 862 + 863 + ### 🧪 Quality Assurance 864 + 865 + - **Comprehensive Test Coverage**: Unit, integration, and i18n-specific tests 866 + - **Translation Validation**: Automated checking for missing keys 867 + - **Manual Testing**: Cross-language functionality validation 868 + - **Performance Monitoring**: Cache hit rates and response times 869 + 870 + --- 871 + 872 + ## Risk Mitigation 873 + 874 + ### Potential Issues 875 + 876 + 1. **Cache Key Explosion**: Including locale in cache keys increases memory usage 877 + - **Solution**: Implement cache size limits and intelligent eviction 878 + 879 + 2. **Translation Key Mismatches**: Category names may not match translation keys 880 + - **Solution**: Fallback logic to generate readable names from keys 881 + 882 + 3. **Template Complexity**: Unified templates may be harder to debug 883 + - **Solution**: Maintain clear separation and good commenting 884 + 885 + ### Rollback Plan 886 + 887 + - **Feature Flag**: Implement `enable_filter_i18n` flag for quick disable 888 + - **Template Fallback**: Keep original language-specific templates as backup 889 + - **Cache Compatibility**: Ensure new cache keys don't break existing cache 890 + 891 + --- 892 + 893 + ## Conclusion 894 + 895 + Phase 4 delivers a fully internationalized filtering system that seamlessly integrates with the existing i18n infrastructure. The implementation maintains backward compatibility while providing significant improvements in user experience for French Canadian users and establishes a solid foundation for additional languages in the future. 896 + 897 + The approach emphasizes performance (static loading, intelligent caching), maintainability (unified templates, clear fallbacks), and user experience (HTMX-aware language propagation, locale-specific facets).
+38 -1
i18n/en-us/ui.ftl
··· 315 315 filter-per-page = per page 316 316 filter-loading = Loading events... 317 317 filter-facets-title = Filter Options 318 - filter-categories-label = Categories 318 + filter-modes-label = Event Type 319 + filter-statuses-label = Event Status 319 320 filter-creators-label = Event Organizers 320 321 filter-results-title = Events Found 321 322 filter-no-results-title = No events found 322 323 filter-no-results-message = Try adjusting your search criteria or location filters. 323 324 325 + # Event Mode Labels 326 + community-lexicon-calendar-event-mode-inperson = In Person 327 + community-lexicon-calendar-event-mode-virtual = Virtual 328 + community-lexicon-calendar-event-mode-hybrid = Hybrid 329 + 330 + # Event Status Labels 331 + community-lexicon-calendar-event-status-scheduled = Scheduled 332 + community-lexicon-calendar-event-status-rescheduled = Rescheduled 333 + community-lexicon-calendar-event-status-cancelled = Cancelled 334 + community-lexicon-calendar-event-status-postponed = Postponed 335 + community-lexicon-calendar-event-status-planned = Planned 336 + 324 337 # Pagination 325 338 pagination-previous = Previous 326 339 pagination-next = Next ··· 337 350 # Event details 338 351 event-starts = Starts 339 352 event-location = Location 353 + 354 + # Event Category Facets 355 + category-arts-and-culture = Arts & Culture 356 + category-business-and-networking = Business & Networking 357 + category-community-and-social = Community & Social 358 + category-education-and-learning = Education & Learning 359 + category-family-and-kids = Family & Kids 360 + category-food-and-drink = Food & Drink 361 + category-health-and-wellness = Health & Wellness 362 + category-music-and-entertainment = Music & Entertainment 363 + category-outdoor-and-recreation = Outdoor & Recreation 364 + category-politics-and-activism = Politics & Activism 365 + category-religion-and-spirituality = Religion & Spirituality 366 + category-sports-and-fitness = Sports & Fitness 367 + category-technology-and-innovation = Technology & Innovation 368 + category-travel-and-adventure = Travel & Adventure 369 + category-volunteer-and-charity = Volunteer & Charity 370 + 371 + # Date Range Facets 372 + date-range-today = Today 373 + date-range-this-week = This Week 374 + date-range-this-month = This Month 375 + date-range-next-week = Next Week 376 + date-range-next-month = Next Month
+38 -1
i18n/fr-ca/ui.ftl
··· 315 315 filter-per-page = par page 316 316 filter-loading = Chargement des événements... 317 317 filter-facets-title = Options de filtrage 318 - filter-categories-label = Catégories 318 + filter-modes-label = Type d'événement 319 + filter-statuses-label = Statut de l'événement 319 320 filter-creators-label = Organisateurs d'événements 320 321 filter-results-title = Événements trouvés 321 322 filter-no-results-title = Aucun événement trouvé 322 323 filter-no-results-message = Essayez d'ajuster vos critères de recherche ou filtres de lieu. 323 324 325 + # Étiquettes de mode d'événement 326 + community-lexicon-calendar-event-mode-inperson = En personne 327 + community-lexicon-calendar-event-mode-virtual = Virtuel 328 + community-lexicon-calendar-event-mode-hybrid = Hybride 329 + 330 + # Étiquettes de statut d'événement 331 + community-lexicon-calendar-event-status-scheduled = Programmé 332 + community-lexicon-calendar-event-status-rescheduled = Reprogrammé 333 + community-lexicon-calendar-event-status-cancelled = Annulé 334 + community-lexicon-calendar-event-status-postponed = Reporté 335 + community-lexicon-calendar-event-status-planned = Planifié 336 + 324 337 # Pagination 325 338 pagination-previous = Précédent 326 339 pagination-next = Suivant ··· 337 350 # Détails de l'événement 338 351 event-starts = Commence 339 352 event-location = Lieu 353 + 354 + # Facettes de catégories d'événements 355 + category-arts-and-culture = Arts et culture 356 + category-business-and-networking = Affaires et réseautage 357 + category-community-and-social = Communauté et social 358 + category-education-and-learning = Éducation et apprentissage 359 + category-family-and-kids = Famille et enfants 360 + category-food-and-drink = Nourriture et boissons 361 + category-health-and-wellness = Santé et bien-être 362 + category-music-and-entertainment = Musique et divertissement 363 + category-outdoor-and-recreation = Plein air et loisirs 364 + category-politics-and-activism = Politique et activisme 365 + category-religion-and-spirituality = Religion et spiritualité 366 + category-sports-and-fitness = Sports et conditionnement physique 367 + category-technology-and-innovation = Technologie et innovation 368 + category-travel-and-adventure = Voyage et aventure 369 + category-volunteer-and-charity = Bénévolat et charité 370 + 371 + # Facettes de plages de dates 372 + date-range-today = Aujourd'hui 373 + date-range-this-week = Cette semaine 374 + date-range-this-month = Ce mois-ci 375 + date-range-next-week = La semaine prochaine 376 + date-range-next-month = Le mois prochain
+2 -2
src/atproto/lexicon/community_lexicon_calendar_event.rs
··· 9 9 10 10 pub const NSID: &str = "community.lexicon.calendar.event"; 11 11 12 - #[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default)] 12 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default, Hash)] 13 13 pub enum Status { 14 14 #[default] 15 15 #[serde(rename = "community.lexicon.calendar.event#scheduled")] ··· 28 28 Planned, 29 29 } 30 30 31 - #[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default)] 31 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default, Hash)] 32 32 pub enum Mode { 33 33 #[default] 34 34 #[serde(rename = "community.lexicon.calendar.event#inperson")]
+6 -2
src/filtering/cache_integration_test.rs
··· 30 30 31 31 #[test] 32 32 fn test_cache_key_generation() { 33 + use crate::atproto::lexicon::community::lexicon::calendar::event::{Mode, Status}; 34 + 33 35 // Test that cache keys are generated consistently 34 36 let criteria = EventFilterCriteria { 35 37 search_term: Some("test".to_string()), 36 - categories: vec!["Technology".to_string()], 38 + modes: vec![Mode::InPerson], 39 + statuses: vec![Status::Scheduled], 37 40 ..Default::default() 38 41 }; 39 42 ··· 46 49 // Different criteria should generate different hash 47 50 let criteria2 = EventFilterCriteria { 48 51 search_term: Some("different".to_string()), 49 - categories: vec!["Technology".to_string()], 52 + modes: vec![Mode::InPerson], 53 + statuses: vec![Status::Scheduled], 50 54 ..Default::default() 51 55 }; 52 56
+18 -6
src/filtering/criteria.rs
··· 8 8 use std::collections::hash_map::DefaultHasher; 9 9 use std::hash::{Hash, Hasher}; 10 10 11 + use crate::atproto::lexicon::community::lexicon::calendar::event::{Mode, Status}; 12 + 11 13 /// Main filter criteria structure containing all possible filter options 12 14 #[derive(Debug, Clone, Serialize, Deserialize, Default, Hash)] 13 15 pub struct EventFilterCriteria { ··· 26 28 /// Location-based filtering 27 29 pub location: Option<LocationFilter>, 28 30 29 - /// Categories to filter by 30 - pub categories: Vec<String>, 31 + /// Filter by event mode (InPerson, Virtual, Hybrid) 32 + pub modes: Vec<Mode>, 33 + 34 + /// Filter by event status (Scheduled, Cancelled, etc.) 35 + pub statuses: Vec<Status>, 31 36 32 37 /// Sorting configuration 33 38 pub sort_by: EventSortField, ··· 126 131 self 127 132 } 128 133 129 - /// Add category filter 130 - pub fn with_category(mut self, category: impl Into<String>) -> Self { 131 - self.categories.push(category.into()); 134 + /// Add mode filter 135 + pub fn with_mode(mut self, mode: Mode) -> Self { 136 + self.modes.push(mode); 137 + self 138 + } 139 + 140 + /// Add status filter 141 + pub fn with_status(mut self, status: Status) -> Self { 142 + self.statuses.push(status); 132 143 self 133 144 } 134 145 ··· 153 164 || self.end_date.is_some() 154 165 || self.creator_did.is_some() 155 166 || self.location.is_some() 156 - || !self.categories.is_empty() 167 + || !self.modes.is_empty() 168 + || !self.statuses.is_empty() 157 169 } 158 170 159 171 /// Generate a cache hash for this criteria
+141 -42
src/filtering/facets.rs
··· 9 9 use chrono::Datelike; 10 10 11 11 use super::{EventFilterCriteria, FilterError}; 12 + use crate::atproto::lexicon::community::lexicon::calendar::event::{Mode, Status}; 12 13 13 14 /// Represents a single facet value with its count 14 15 #[derive(Debug, Clone, Serialize, Deserialize)] ··· 21 22 /// Collection of facets for different dimensions 22 23 #[derive(Debug, Clone, Serialize, Deserialize, Default)] 23 24 pub struct EventFacets { 24 - pub categories: Vec<FacetValue>, 25 + pub modes: Vec<FacetValue>, 26 + pub statuses: Vec<FacetValue>, 25 27 pub creators: Vec<FacetValue>, 26 28 pub date_ranges: Vec<FacetValue>, 27 29 pub total_count: i64, ··· 50 52 // Calculate total count 51 53 facets.total_count = self.calculate_total_count(criteria).await?; 52 54 53 - // Calculate category facets 54 - facets.categories = self.calculate_category_facets(criteria).await?; 55 + // Calculate mode facets 56 + facets.modes = self.calculate_mode_facets(criteria).await?; 57 + 58 + // Calculate status facets 59 + facets.statuses = self.calculate_status_facets(criteria).await?; 55 60 56 61 // Calculate creator facets 57 62 facets.creators = self.calculate_creator_facets(criteria).await?; ··· 77 82 Ok(count.0) 78 83 } 79 84 80 - /// Calculate category facets 81 - async fn calculate_category_facets( 85 + /// Calculate mode facets 86 + async fn calculate_mode_facets( 82 87 &self, 83 88 criteria: &EventFilterCriteria, 84 89 ) -> Result<Vec<FacetValue>, FilterError> { 85 90 let mut query = QueryBuilder::new( 86 - "SELECT jsonb_array_elements_text(record->'categories') as category, COUNT(*) as count 87 - FROM events" 91 + "SELECT record->>'mode' as mode, COUNT(*) as count FROM events" 88 92 ); 89 93 90 - // Apply filters excluding categories to show all available categories 91 - let criteria_without_categories = EventFilterCriteria { 92 - categories: vec![], // Exclude category filters for facet calculation 94 + // Apply filters excluding modes to show all available modes 95 + let criteria_without_modes = EventFilterCriteria { 96 + modes: vec![], // Exclude mode filters for facet calculation 93 97 ..criteria.clone() 94 98 }; 95 - self.apply_base_filters(&mut query, &criteria_without_categories); 99 + self.apply_base_filters(&mut query, &criteria_without_modes); 96 100 97 - query.push(" AND record->'categories' IS NOT NULL"); 98 - query.push(" GROUP BY category"); 99 - query.push(" ORDER BY count DESC, category ASC"); 100 - query.push(" LIMIT 50"); // Limit facet results 101 + query.push(" AND record->>'mode' IS NOT NULL"); 102 + query.push(" GROUP BY mode"); 103 + query.push(" ORDER BY count DESC, mode ASC"); 101 104 102 105 let rows = query 103 106 .build() ··· 106 109 107 110 let mut facets = Vec::new(); 108 111 for row in rows { 109 - let category: String = row.try_get("category")?; 112 + let mode_str: Option<String> = row.try_get("mode")?; 110 113 let count: i64 = row.try_get("count")?; 111 114 112 - facets.push(FacetValue { 113 - i18n_key: Some(Self::generate_category_i18n_key(&category)), 114 - value: category, 115 - count, 116 - }); 115 + if let Some(mode_str) = mode_str { 116 + facets.push(FacetValue { 117 + value: mode_str.clone(), 118 + count, 119 + i18n_key: Some(Self::generate_mode_i18n_key(&mode_str)), 120 + }); 121 + } 122 + } 123 + 124 + Ok(facets) 125 + } 126 + 127 + /// Calculate status facets 128 + async fn calculate_status_facets( 129 + &self, 130 + criteria: &EventFilterCriteria, 131 + ) -> Result<Vec<FacetValue>, FilterError> { 132 + let mut query = QueryBuilder::new( 133 + "SELECT record->>'status' as status, COUNT(*) as count FROM events" 134 + ); 135 + 136 + // Apply filters excluding statuses to show all available statuses 137 + let criteria_without_statuses = EventFilterCriteria { 138 + statuses: vec![], // Exclude status filters for facet calculation 139 + ..criteria.clone() 140 + }; 141 + self.apply_base_filters(&mut query, &criteria_without_statuses); 142 + 143 + query.push(" AND record->>'status' IS NOT NULL"); 144 + query.push(" GROUP BY status"); 145 + query.push(" ORDER BY count DESC, status ASC"); 146 + 147 + let rows = query 148 + .build() 149 + .fetch_all(&self.pool) 150 + .await?; 151 + 152 + let mut facets = Vec::new(); 153 + for row in rows { 154 + let status_str: Option<String> = row.try_get("status")?; 155 + let count: i64 = row.try_get("count")?; 156 + 157 + if let Some(status_str) = status_str { 158 + facets.push(FacetValue { 159 + value: status_str.clone(), 160 + count, 161 + i18n_key: Some(Self::generate_status_i18n_key(&status_str)), 162 + }); 163 + } 117 164 } 118 165 119 166 Ok(facets) ··· 181 228 facets.push(FacetValue { 182 229 value: key.to_string(), 183 230 count, 184 - i18n_key: Some(format!("date_range.{}", key)), 231 + i18n_key: Some(format!("date-range-{}", key)), 185 232 }); 186 233 } 187 234 } ··· 310 357 has_where = true; 311 358 } 312 359 313 - // Category filtering 314 - if !criteria.categories.is_empty() { 360 + // Mode filtering 361 + if !criteria.modes.is_empty() { 315 362 query.push(if has_where { " AND " } else { " WHERE " }); 316 363 query.push("("); 317 - for (i, category) in criteria.categories.iter().enumerate() { 364 + for (i, mode) in criteria.modes.iter().enumerate() { 318 365 if i > 0 { 319 366 query.push(" OR "); 320 367 } 321 - query.push("record->'categories' ? "); 322 - query.push_bind(category); 368 + // Convert Mode enum to its string representation 369 + let mode_str = match mode { 370 + Mode::InPerson => "community.lexicon.calendar.event#inperson", 371 + Mode::Virtual => "community.lexicon.calendar.event#virtual", 372 + Mode::Hybrid => "community.lexicon.calendar.event#hybrid", 373 + }; 374 + query.push("record->>'mode' = "); 375 + query.push_bind(mode_str); 376 + } 377 + query.push(")"); 378 + has_where = true; 379 + } 380 + 381 + // Status filtering 382 + if !criteria.statuses.is_empty() { 383 + query.push(if has_where { " AND " } else { " WHERE " }); 384 + query.push("("); 385 + for (i, status) in criteria.statuses.iter().enumerate() { 386 + if i > 0 { 387 + query.push(" OR "); 388 + } 389 + // Convert Status enum to its string representation 390 + let status_str = match status { 391 + Status::Scheduled => "community.lexicon.calendar.event#scheduled", 392 + Status::Rescheduled => "community.lexicon.calendar.event#rescheduled", 393 + Status::Cancelled => "community.lexicon.calendar.event#cancelled", 394 + Status::Postponed => "community.lexicon.calendar.event#postponed", 395 + Status::Planned => "community.lexicon.calendar.event#planned", 396 + }; 397 + query.push("record->>'status' = "); 398 + query.push_bind(status_str); 323 399 } 324 400 query.push(")"); 325 401 has_where = true; ··· 343 419 } 344 420 } 345 421 346 - /// Generate i18n key for category facets 347 - fn generate_category_i18n_key(category: &str) -> String { 348 - format!("category.{}", 349 - category.to_lowercase() 350 - .replace(" ", "_") 351 - .replace("&", "and") 352 - .chars() 353 - .filter(|c| c.is_alphanumeric() || *c == '_') 354 - .collect::<String>() 355 - ) 422 + /// Generate i18n key for mode facets 423 + fn generate_mode_i18n_key(mode: &str) -> String { 424 + match mode { 425 + "community.lexicon.calendar.event#inperson" => "mode-inperson".to_string(), 426 + "community.lexicon.calendar.event#virtual" => "mode-virtual".to_string(), 427 + "community.lexicon.calendar.event#hybrid" => "mode-hybrid".to_string(), 428 + _ => format!("mode-{}", mode.to_lowercase().replace('#', "-").replace('.', "-")), 429 + } 430 + } 431 + 432 + /// Generate i18n key for status facets 433 + fn generate_status_i18n_key(status: &str) -> String { 434 + match status { 435 + "community.lexicon.calendar.event#scheduled" => "status-scheduled".to_string(), 436 + "community.lexicon.calendar.event#rescheduled" => "status-rescheduled".to_string(), 437 + "community.lexicon.calendar.event#cancelled" => "status-cancelled".to_string(), 438 + "community.lexicon.calendar.event#postponed" => "status-postponed".to_string(), 439 + "community.lexicon.calendar.event#planned" => "status-planned".to_string(), 440 + _ => format!("status-{}", status.to_lowercase().replace('#', "-").replace('.', "-")), 441 + } 356 442 } 357 443 } 358 444 ··· 361 447 use super::*; 362 448 363 449 #[test] 364 - fn test_generate_category_i18n_key() { 450 + fn test_generate_mode_i18n_key() { 365 451 assert_eq!( 366 - FacetCalculator::generate_category_i18n_key("Technology & Innovation"), 367 - "category.technology_and_innovation" 452 + FacetCalculator::generate_mode_i18n_key("community.lexicon.calendar.event#inperson"), 453 + "mode-inperson" 368 454 ); 369 455 370 456 assert_eq!( 371 - FacetCalculator::generate_category_i18n_key("Arts & Culture"), 372 - "category.arts_and_culture" 457 + FacetCalculator::generate_mode_i18n_key("community.lexicon.calendar.event#virtual"), 458 + "mode-virtual" 459 + ); 460 + } 461 + 462 + #[test] 463 + fn test_generate_status_i18n_key() { 464 + assert_eq!( 465 + FacetCalculator::generate_status_i18n_key("community.lexicon.calendar.event#scheduled"), 466 + "status-scheduled" 467 + ); 468 + 469 + assert_eq!( 470 + FacetCalculator::generate_status_i18n_key("community.lexicon.calendar.event#cancelled"), 471 + "status-cancelled" 373 472 ); 374 473 } 375 474 }
+3
src/filtering/mod.rs
··· 13 13 #[cfg(test)] 14 14 mod cache_integration_test; 15 15 16 + #[cfg(test)] 17 + mod mode_status_integration_test; 18 + 16 19 pub use criteria::*; 17 20 pub use errors::*; 18 21 pub use facets::*;
+91
src/filtering/mode_status_integration_test.rs
··· 1 + // Integration test for mode and status filtering 2 + // 3 + // Tests the complete filtering pipeline from HTTP parameters to SQL queries 4 + 5 + #[cfg(test)] 6 + mod tests { 7 + use crate::filtering::{EventFilterCriteria, EventFacets, FacetValue}; 8 + use crate::atproto::lexicon::community::lexicon::calendar::event::{Mode, Status}; 9 + 10 + #[test] 11 + fn test_mode_status_filtering_pipeline() { 12 + // Test direct criteria building with modes and statuses 13 + let criteria = EventFilterCriteria::new() 14 + .with_mode(Mode::InPerson) 15 + .with_mode(Mode::Virtual) 16 + .with_status(Status::Scheduled) 17 + .with_status(Status::Cancelled); 18 + 19 + assert_eq!(criteria.modes.len(), 2); 20 + assert!(criteria.modes.contains(&Mode::InPerson)); 21 + assert!(criteria.modes.contains(&Mode::Virtual)); 22 + 23 + assert_eq!(criteria.statuses.len(), 2); 24 + assert!(criteria.statuses.contains(&Status::Scheduled)); 25 + assert!(criteria.statuses.contains(&Status::Cancelled)); 26 + } 27 + 28 + #[test] 29 + fn test_mode_status_facet_generation() { 30 + // Test that facets can be generated for modes and statuses 31 + let modes = vec![ 32 + FacetValue { 33 + value: "inperson".to_string(), 34 + count: 5, 35 + i18n_key: Some("community-lexicon-calendar-event-mode-inperson".to_string()) 36 + }, 37 + FacetValue { 38 + value: "virtual".to_string(), 39 + count: 3, 40 + i18n_key: Some("community-lexicon-calendar-event-mode-virtual".to_string()) 41 + }, 42 + ]; 43 + 44 + let statuses = vec![ 45 + FacetValue { 46 + value: "scheduled".to_string(), 47 + count: 8, 48 + i18n_key: Some("community-lexicon-calendar-event-status-scheduled".to_string()) 49 + }, 50 + ]; 51 + 52 + let facets = EventFacets { 53 + modes, 54 + statuses, 55 + ..Default::default() 56 + }; 57 + 58 + assert_eq!(facets.modes.len(), 2); 59 + assert_eq!(facets.statuses.len(), 1); 60 + assert_eq!(facets.modes[0].count, 5); 61 + assert_eq!(facets.statuses[0].count, 8); 62 + } 63 + 64 + #[test] 65 + fn test_criteria_has_filters() { 66 + // Test that criteria correctly reports when filters are active 67 + let empty_criteria = EventFilterCriteria::new(); 68 + assert!(!empty_criteria.has_filters()); 69 + 70 + let mode_criteria = EventFilterCriteria::new().with_mode(Mode::InPerson); 71 + assert!(mode_criteria.has_filters()); 72 + 73 + let status_criteria = EventFilterCriteria::new().with_status(Status::Scheduled); 74 + assert!(status_criteria.has_filters()); 75 + } 76 + 77 + #[test] 78 + fn test_cache_key_includes_modes_and_statuses() { 79 + // Test that cache keys include mode and status information 80 + let criteria1 = EventFilterCriteria::new() 81 + .with_mode(Mode::InPerson) 82 + .with_status(Status::Scheduled); 83 + 84 + let criteria2 = EventFilterCriteria::new() 85 + .with_mode(Mode::Virtual) 86 + .with_status(Status::Scheduled); 87 + 88 + // Different modes should result in different cache keys 89 + assert_ne!(criteria1.cache_hash(), criteria2.cache_hash()); 90 + } 91 + }
+43 -5
src/filtering/query_builder.rs
··· 8 8 9 9 use super::{EventFilterCriteria, EventSortField, FilterError, LocationFilter, SortOrder}; 10 10 use crate::storage::event::model::Event; 11 + use crate::atproto::lexicon::community::lexicon::calendar::event::{Mode, Status}; 11 12 12 13 /// SQL query builder for event filtering 13 14 #[derive(Debug, Clone)] ··· 120 121 has_where = true; 121 122 } 122 123 123 - // Category filtering 124 - if !criteria.categories.is_empty() { 124 + // Mode filtering 125 + if !criteria.modes.is_empty() { 126 + query.push(if has_where { " AND " } else { " WHERE " }); 127 + query.push("("); 128 + for (i, mode) in criteria.modes.iter().enumerate() { 129 + if i > 0 { 130 + query.push(" OR "); 131 + } 132 + // Convert enum to its serde representation for database comparison 133 + let mode_str = match mode { 134 + Mode::InPerson => 135 + "community.lexicon.calendar.event#inperson", 136 + Mode::Virtual => 137 + "community.lexicon.calendar.event#virtual", 138 + Mode::Hybrid => 139 + "community.lexicon.calendar.event#hybrid", 140 + }; 141 + query.push("record->>'mode' = "); 142 + query.push_bind(mode_str); 143 + } 144 + query.push(")"); 145 + has_where = true; 146 + } 147 + 148 + // Status filtering 149 + if !criteria.statuses.is_empty() { 125 150 query.push(if has_where { " AND " } else { " WHERE " }); 126 151 query.push("("); 127 - for (i, category) in criteria.categories.iter().enumerate() { 152 + for (i, status) in criteria.statuses.iter().enumerate() { 128 153 if i > 0 { 129 154 query.push(" OR "); 130 155 } 131 - query.push("record->'categories' ? "); 132 - query.push_bind(category); 156 + // Convert enum to its serde representation for database comparison 157 + let status_str = match status { 158 + Status::Scheduled => 159 + "community.lexicon.calendar.event#scheduled", 160 + Status::Rescheduled => 161 + "community.lexicon.calendar.event#rescheduled", 162 + Status::Cancelled => 163 + "community.lexicon.calendar.event#cancelled", 164 + Status::Postponed => 165 + "community.lexicon.calendar.event#postponed", 166 + Status::Planned => 167 + "community.lexicon.calendar.event#planned", 168 + }; 169 + query.push("record->>'status' = "); 170 + query.push_bind(status_str); 133 171 } 134 172 query.push(")"); 135 173 has_where = true;
+8 -3
src/filtering/service.rs
··· 347 347 } 348 348 } 349 349 350 - // Validate categories 351 - if criteria.categories.len() > 20 { 352 - return Err(FilterError::invalid_criteria("Too many categories (max 20)")); 350 + // Validate modes 351 + if criteria.modes.len() > 10 { 352 + return Err(FilterError::invalid_criteria("Too many modes (max 10)")); 353 + } 354 + 355 + // Validate statuses 356 + if criteria.statuses.len() > 10 { 357 + return Err(FilterError::invalid_criteria("Too many statuses (max 10)")); 353 358 } 354 359 355 360 Ok(())
+62 -14
src/http/middleware_filter.rs
··· 13 13 use tracing::{instrument, warn}; 14 14 15 15 use crate::filtering::{EventFilterCriteria, EventSortField, LocationFilter, SortOrder}; 16 + use crate::atproto::lexicon::community::lexicon::calendar::event::{Mode, Status}; 16 17 17 18 /// Query parameters for event filtering 18 19 #[derive(Debug, Clone, Deserialize, Serialize)] ··· 29 30 /// Creator DID 30 31 pub creator: Option<String>, 31 32 32 - /// Categories (comma-separated or multiple params) 33 - pub categories: Option<String>, 33 + /// Event modes (comma-separated or multiple params) 34 + pub modes: Option<String>, 35 + 36 + /// Event statuses (comma-separated or multiple params) 37 + pub statuses: Option<String>, 34 38 35 39 /// Location latitude 36 40 pub lat: Option<f64>, ··· 129 133 } 130 134 } 131 135 132 - // Categories 133 - if let Some(ref categories_str) = params.categories { 134 - criteria.categories = parse_categories(categories_str); 136 + // Modes 137 + if let Some(ref modes_str) = params.modes { 138 + criteria.modes = parse_modes(modes_str); 139 + } 140 + 141 + // Statuses 142 + if let Some(ref statuses_str) = params.statuses { 143 + criteria.statuses = parse_statuses(statuses_str); 135 144 } 136 145 137 146 // Location ··· 205 214 } 206 215 } 207 216 208 - /// Parse categories from comma-separated string 209 - fn parse_categories(categories_str: &str) -> Vec<String> { 210 - categories_str 217 + /// Parse modes from comma-separated string 218 + fn parse_modes(modes_str: &str) -> Vec<Mode> { 219 + modes_str 211 220 .split(',') 212 - .map(|s| s.trim().to_string()) 221 + .map(|s| s.trim().to_lowercase()) 213 222 .filter(|s| !s.is_empty()) 223 + .filter_map(|s| match s.as_str() { 224 + "inperson" | "in-person" | "in_person" => Some(Mode::InPerson), 225 + "virtual" => Some(Mode::Virtual), 226 + "hybrid" => Some(Mode::Hybrid), 227 + _ => None, 228 + }) 229 + .collect() 230 + } 231 + 232 + /// Parse statuses from comma-separated string 233 + fn parse_statuses(statuses_str: &str) -> Vec<Status> { 234 + statuses_str 235 + .split(',') 236 + .map(|s| s.trim().to_lowercase()) 237 + .filter(|s| !s.is_empty()) 238 + .filter_map(|s| match s.as_str() { 239 + "scheduled" => Some(Status::Scheduled), 240 + "rescheduled" => Some(Status::Rescheduled), 241 + "cancelled" | "canceled" => Some(Status::Cancelled), 242 + "postponed" => Some(Status::Postponed), 243 + "planned" => Some(Status::Planned), 244 + _ => None, 245 + }) 214 246 .collect() 215 247 } 216 248 ··· 241 273 start_date: None, 242 274 end_date: None, 243 275 creator: None, 244 - categories: None, 276 + modes: None, 277 + statuses: None, 245 278 lat: None, 246 279 lng: None, 247 280 radius: None, ··· 266 299 } 267 300 268 301 #[test] 269 - fn test_parse_categories() { 270 - let categories = parse_categories("tech,music, arts"); 271 - assert_eq!(categories, vec!["tech", "music", "arts"]); 302 + fn test_parse_modes() { 303 + let modes = parse_modes("inperson,virtual,hybrid"); 304 + assert_eq!(modes, vec![Mode::InPerson, Mode::Virtual, Mode::Hybrid]); 272 305 273 - let empty = parse_categories(""); 306 + let with_variations = parse_modes("in-person, virtual, hybrid"); 307 + assert_eq!(with_variations, vec![Mode::InPerson, Mode::Virtual, Mode::Hybrid]); 308 + 309 + let empty = parse_modes(""); 310 + assert!(empty.is_empty()); 311 + } 312 + 313 + #[test] 314 + fn test_parse_statuses() { 315 + let statuses = parse_statuses("scheduled,cancelled,planned"); 316 + assert_eq!(statuses, vec![Status::Scheduled, Status::Cancelled, Status::Planned]); 317 + 318 + let with_variations = parse_statuses("canceled, postponed"); 319 + assert_eq!(with_variations, vec![Status::Cancelled, Status::Postponed]); 320 + 321 + let empty = parse_statuses(""); 274 322 assert!(empty.is_empty()); 275 323 } 276 324
+29 -8
templates/filter_events.en-us.common.html
··· 117 117 <div class="box"> 118 118 <h3 class="title is-6">{{ tr("filter-facets-title") }}</h3> 119 119 120 - <!-- Category Facets --> 121 - {% if facets.categories %} 120 + <!-- Mode Facets --> 121 + {% if facets.modes %} 122 + <div class="field"> 123 + <label class="label">{{ tr("filter-modes-label") }}</label> 124 + {% for mode in facets.modes %} 125 + <div class="control"> 126 + <label class="checkbox"> 127 + <input type="checkbox" 128 + name="modes" 129 + value="{{ mode.name }}" 130 + {% if mode.name in filter_criteria.modes %}checked{% endif %} 131 + hx-get="/events" 132 + hx-target="#events-results" 133 + hx-include="closest form"> 134 + {{ tr(mode.i18n_key) }} ({{ mode.count }}) 135 + </label> 136 + </div> 137 + {% endfor %} 138 + </div> 139 + {% endif %} 140 + 141 + <!-- Status Facets --> 142 + {% if facets.statuses %} 122 143 <div class="field"> 123 - <label class="label">{{ tr("filter-categories-label") }}</label> 124 - {% for category in facets.categories %} 144 + <label class="label">{{ tr("filter-statuses-label") }}</label> 145 + {% for status in facets.statuses %} 125 146 <div class="control"> 126 147 <label class="checkbox"> 127 148 <input type="checkbox" 128 - name="categories" 129 - value="{{ category.name }}" 130 - {% if category.name in filter_criteria.categories %}checked{% endif %} 149 + name="statuses" 150 + value="{{ status.name }}" 151 + {% if status.name in filter_criteria.statuses %}checked{% endif %} 131 152 hx-get="/events" 132 153 hx-target="#events-results" 133 154 hx-include="closest form"> 134 - {{ category.name }} ({{ category.count }}) 155 + {{ tr(status.i18n_key) }} ({{ status.count }}) 135 156 </label> 136 157 </div> 137 158 {% endfor %}
+29 -8
templates/filter_events.fr-ca.common.html
··· 117 117 <div class="box"> 118 118 <h3 class="title is-6">{{ tr("filter-facets-title") }}</h3> 119 119 120 - <!-- Category Facets --> 121 - {% if facets.categories %} 120 + <!-- Mode Facets --> 121 + {% if facets.modes %} 122 + <div class="field"> 123 + <label class="label">{{ tr("filter-modes-label") }}</label> 124 + {% for mode in facets.modes %} 125 + <div class="control"> 126 + <label class="checkbox"> 127 + <input type="checkbox" 128 + name="modes" 129 + value="{{ mode.name }}" 130 + {% if mode.name in filter_criteria.modes %}checked{% endif %} 131 + hx-get="/events" 132 + hx-target="#events-results" 133 + hx-include="closest form"> 134 + {{ tr(mode.i18n_key) }} ({{ mode.count }}) 135 + </label> 136 + </div> 137 + {% endfor %} 138 + </div> 139 + {% endif %} 140 + 141 + <!-- Status Facets --> 142 + {% if facets.statuses %} 122 143 <div class="field"> 123 - <label class="label">{{ tr("filter-categories-label") }}</label> 124 - {% for category in facets.categories %} 144 + <label class="label">{{ tr("filter-statuses-label") }}</label> 145 + {% for status in facets.statuses %} 125 146 <div class="control"> 126 147 <label class="checkbox"> 127 148 <input type="checkbox" 128 - name="categories" 129 - value="{{ category.name }}" 130 - {% if category.name in filter_criteria.categories %}checked{% endif %} 149 + name="statuses" 150 + value="{{ status.name }}" 151 + {% if status.name in filter_criteria.statuses %}checked{% endif %} 131 152 hx-get="/events" 132 153 hx-target="#events-results" 133 154 hx-include="closest form"> 134 - {{ category.name }} ({{ category.count }}) 155 + {{ tr(status.i18n_key) }} ({{ status.count }}) 135 156 </label> 136 157 </div> 137 158 {% endfor %}