···11+# Event Filtering System - Phase 4: I18n Integration Plan
22+33+## Overview
44+55+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.
66+77+**Status**: Ready for Implementation ✅
88+**Prerequisites**: Phase 1-3 Complete, I18n Infrastructure Complete
99+**Estimated Effort**: 2-3 days
1010+1111+---
1212+1313+## Current State Analysis
1414+1515+### ✅ Infrastructure Ready
1616+1717+#### I18n System
1818+- **fluent-templates static loader**: Fully implemented and working
1919+- **Template functions**: `t()`, `tg()`, `current_locale()`, `has_locale()` already available
2020+- **HTMX middleware**: Language detection with proper priority order implemented
2121+- **Gender support**: French Canadian gender variants working
2222+2323+#### Filtering System
2424+- **Phase 1**: Core filtering, query builder, facets, hydration complete
2525+- **Phase 2**: Facet calculation and event hydration complete
2626+- **Phase 3**: Redis cache integration complete
2727+- **Templates**: Language-specific templates exist (`.en-us.html`, `.fr-ca.html`)
2828+2929+#### Translation Keys
3030+- **Basic filter keys**: Most UI strings already translated in `i18n/*/ui.ftl`
3131+- **Missing**: Facet-specific translation keys for dynamic content
3232+3333+### 🔧 Implementation Needed
3434+3535+1. **Facet Translation Keys**: Add missing keys for category/date range facets
3636+2. **Locale-Aware Facet Calculation**: Pass locale context to facet calculators
3737+3. **Template Migration**: Update templates to use i18n functions instead of pre-rendered text
3838+4. **Service Integration**: Connect filtering service with locale from middleware
3939+5. **HTMX Language Headers**: Ensure facet updates propagate language correctly
4040+4141+---
4242+4343+## Implementation Tasks
4444+4545+### Task 1: Add Missing Translation Keys
4646+4747+#### 1.2 Date Range Facet Keys
4848+Add date range translation keys:
4949+5050+**File**: `/i18n/en-us/ui.ftl`
5151+```fluent
5252+# Date range facets
5353+date_range.today = Today
5454+date_range.this_week = This Week
5555+date_range.this_month = This Month
5656+date_range.next_week = Next Week
5757+date_range.next_month = Next Month
5858+```
5959+6060+**File**: `/i18n/fr-ca/ui.ftl`
6161+```fluent
6262+# Facettes de plage de dates
6363+date_range.today = Aujourd'hui
6464+date_range.this_week = Cette semaine
6565+date_range.this_month = Ce mois
6666+date_range.next_week = La semaine prochaine
6767+date_range.next_month = Le mois prochain
6868+```
6969+7070+#### 1.3 Additional Facet UI Keys
7171+Extend existing filter keys for facet display:
7272+7373+**File**: `/i18n/en-us/ui.ftl`
7474+```fluent
7575+# Facet labels and counts
7676+filter-categories-label = Categories
7777+filter-creators-label = Event Creators
7878+filter-date-ranges-label = Date Ranges
7979+filter-facet-count = { $count ->
8080+ [one] ({ $count } event)
8181+ *[other] ({ $count } events)
8282+}
8383+filter-clear-facet = Clear { $facet }
8484+filter-show-more-facets = Show more...
8585+filter-show-less-facets = Show less
8686+```
8787+8888+**File**: `/i18n/fr-ca/ui.ftl`
8989+```fluent
9090+# Étiquettes et comptes de facettes
9191+filter-categories-label = Catégories
9292+filter-creators-label = Créateurs d'événements
9393+filter-date-ranges-label = Plages de dates
9494+filter-facet-count = { $count ->
9595+ [one] ({ $count } événement)
9696+ *[other] ({ $count } événements)
9797+}
9898+filter-clear-facet = Effacer { $facet }
9999+filter-show-more-facets = Voir plus...
100100+filter-show-less-facets = Voir moins
101101+```
102102+103103+### Task 2: Update Facet Calculation Service
104104+105105+#### 2.1 Add Locale Parameter to FacetCalculator
106106+**File**: `/src/filtering/facets.rs`
107107+108108+```rust
109109+impl FacetCalculator {
110110+ /// Calculate all facets for the given filter criteria with locale support
111111+ #[instrument(skip(self, criteria))]
112112+ pub async fn calculate_facets_with_locale(
113113+ &self,
114114+ criteria: &EventFilterCriteria,
115115+ locale: &LanguageIdentifier,
116116+ ) -> Result<EventFacets, FilterError> {
117117+ let mut facets = EventFacets::default();
118118+119119+ // Calculate total count
120120+ facets.total_count = self.calculate_total_count(criteria).await?;
121121+122122+ // Calculate category facets with i18n support
123123+ facets.categories = self.calculate_category_facets_with_locale(criteria, locale).await?;
124124+125125+ // Calculate creator facets
126126+ facets.creators = self.calculate_creator_facets(criteria).await?;
127127+128128+ // Calculate date range facets with i18n support
129129+ facets.date_ranges = self.calculate_date_range_facets_with_locale(criteria, locale).await?;
130130+131131+ Ok(facets)
132132+ }
133133+134134+ /// Calculate category facets with locale-aware translations
135135+ async fn calculate_category_facets_with_locale(
136136+ &self,
137137+ criteria: &EventFilterCriteria,
138138+ locale: &LanguageIdentifier,
139139+ ) -> Result<Vec<FacetValue>, FilterError> {
140140+ // ...existing query logic...
141141+142142+ let mut facets = Vec::new();
143143+ for row in rows {
144144+ let category: String = row.try_get("category")?;
145145+ let count: i64 = row.try_get("count")?;
146146+147147+ let i18n_key = Self::generate_category_i18n_key(&category);
148148+149149+ facets.push(FacetValue {
150150+ i18n_key: Some(i18n_key),
151151+ value: category,
152152+ count,
153153+ // Add locale-specific display name if translation exists
154154+ display_name: Some(self.get_translated_facet_name(&i18n_key, locale)),
155155+ });
156156+ }
157157+158158+ Ok(facets)
159159+ }
160160+161161+ /// Get translated facet name with fallback to original value
162162+ fn get_translated_facet_name(&self, i18n_key: &str, locale: &LanguageIdentifier) -> String {
163163+ use crate::i18n::fluent_loader::LOCALES;
164164+165165+ let translated = LOCALES.lookup(locale, i18n_key);
166166+167167+ // If translation returns the key itself, it means no translation found
168168+ if translated == i18n_key {
169169+ // Extract readable name from key: "category.technology_and_innovation" -> "Technology And Innovation"
170170+ i18n_key
171171+ .split('.')
172172+ .last()
173173+ .unwrap_or(i18n_key)
174174+ .replace('_', " ")
175175+ .split_whitespace()
176176+ .map(|word| {
177177+ let mut chars = word.chars();
178178+ match chars.next() {
179179+ None => String::new(),
180180+ Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
181181+ }
182182+ })
183183+ .collect::<Vec<_>>()
184184+ .join(" ")
185185+ } else {
186186+ translated
187187+ }
188188+ }
189189+}
190190+```
191191+192192+#### 2.2 Update FacetValue Structure
193193+**File**: `/src/filtering/mod.rs`
194194+195195+```rust
196196+/// A single facet value with count and optional i18n support
197197+#[derive(Debug, Clone, Serialize, Deserialize)]
198198+pub struct FacetValue {
199199+ /// The actual filter value
200200+ pub value: String,
201201+ /// Number of events matching this facet
202202+ pub count: i64,
203203+ /// Optional i18n key for translation
204204+ pub i18n_key: Option<String>,
205205+ /// Pre-calculated display name (locale-specific)
206206+ pub display_name: Option<String>,
207207+}
208208+```
209209+210210+### Task 3: Update FilteringService Integration
211211+212212+#### 3.1 Pass Locale to Service
213213+**File**: `/src/filtering/service.rs`
214214+215215+```rust
216216+impl FilteringService {
217217+ /// Filter events with locale-aware facets
218218+ #[instrument(skip(self, criteria))]
219219+ pub async fn filter_events_with_locale(
220220+ &self,
221221+ criteria: &EventFilterCriteria,
222222+ locale: &LanguageIdentifier,
223223+ options: FilterOptions,
224224+ ) -> Result<FilterResults, FilterError> {
225225+ // Generate cache key including locale
226226+ let cache_key = format!("filter:{}:{}", criteria.cache_hash(), locale.to_string());
227227+228228+ // Try cache first (if enabled)
229229+ if let Some(ref cache) = self.cache {
230230+ if let Ok(Some(cached_results)) = cache.get::<FilterResults>(&cache_key).await {
231231+ debug!("Cache hit for filter query with locale {}", locale);
232232+ return Ok(cached_results);
233233+ }
234234+ }
235235+236236+ // Execute query
237237+ let events = self.query_builder
238238+ .filter_events(criteria, &self.pool, &options)
239239+ .await?;
240240+241241+ // Calculate locale-aware facets
242242+ let facets = if options.include_facets {
243243+ Some(self.facet_calculator.calculate_facets_with_locale(criteria, locale).await?)
244244+ } else {
245245+ None
246246+ };
247247+248248+ // Hydrate events if requested
249249+ let hydrated_events = if options.include_hydration {
250250+ self.hydrator.hydrate_events(events, &options.hydration_options).await?
251251+ } else {
252252+ events.into_iter().map(EventView::from_event).collect()
253253+ };
254254+255255+ let results = FilterResults {
256256+ events: hydrated_events,
257257+ facets,
258258+ total_count: facets.as_ref().map(|f| f.total_count).unwrap_or(0),
259259+ };
260260+261261+ // Cache results (if enabled)
262262+ if let Some(ref cache) = self.cache {
263263+ let _ = cache.set(&cache_key, &results, self.config.cache_ttl).await;
264264+ }
265265+266266+ Ok(results)
267267+ }
268268+}
269269+```
270270+271271+### Task 4: Update HTTP Handlers
272272+273273+#### 4.1 Extract Locale from Middleware
274274+**File**: `/src/http/handle_filter_events.rs`
275275+276276+```rust
277277+use crate::http::middleware_i18n::Language;
278278+279279+/// Main filtering endpoint with i18n support
280280+#[instrument(skip(ctx, filtering_service))]
281281+pub async fn handle_filter_events(
282282+ Extension(ctx): Extension<WebContext>,
283283+ Extension(filtering_service): Extension<Arc<FilteringService>>,
284284+ language: Language,
285285+ filter_params: FilterQueryParams,
286286+ headers: HeaderMap,
287287+) -> Result<impl IntoResponse, WebError> {
288288+ let is_htmx = headers.contains_key("hx-request");
289289+ let criteria = EventFilterCriteria::from_query_params(&filter_params)?;
290290+291291+ // Include locale in filtering options
292292+ let options = FilterOptions {
293293+ include_facets: true,
294294+ include_hydration: true,
295295+ hydration_options: HydrationOptions::default(),
296296+ };
297297+298298+ // Use locale-aware filtering
299299+ let results = filtering_service
300300+ .filter_events_with_locale(&criteria, &language.0, options)
301301+ .await?;
302302+303303+ // Template selection with locale
304304+ let template_name = if is_htmx {
305305+ format!("filter_events_results.{}.incl.html", language.0)
306306+ } else {
307307+ format!("filter_events.{}.html", language.0)
308308+ };
309309+310310+ // Enhanced context for templates
311311+ let template_context = template_context! {
312312+ events => results.events,
313313+ facets => results.facets,
314314+ filter_criteria => criteria,
315315+ total_count => results.total_count,
316316+ current_locale => language.0.to_string(),
317317+ };
318318+319319+ Ok(RenderHtml(template_name, ctx.engine, template_context))
320320+}
321321+```
322322+323323+### Task 5: Update Templates to Use I18n Functions
324324+325325+#### 5.1 Update Filter Form Template
326326+**File**: `/templates/filter_events.en-us.common.html` → **Migrate to Single Template**
327327+328328+Create new unified template: `/templates/filter_events.common.html`
329329+330330+```html
331331+<div class="section">
332332+ <div class="container">
333333+ <div class="columns">
334334+ <!-- Filter Sidebar -->
335335+ <div class="column is-3">
336336+ <div class="box">
337337+ <h2 class="title is-5">{{ t("filter-title") }}</h2>
338338+339339+ <!-- Search Form -->
340340+ <form id="filter-form"
341341+ hx-get="/events"
342342+ hx-target="#events-results"
343343+ hx-trigger="submit, change from:input, keyup[keyCode==13] from:input"
344344+ hx-push-url="true"
345345+ hx-headers='{"HX-Current-Language": "{{ current_locale() }}"}'>
346346+347347+ <!-- Text Search -->
348348+ <div class="field">
349349+ <label class="label">{{ t("filter-search-label") }}</label>
350350+ <div class="control">
351351+ <input class="input"
352352+ type="text"
353353+ name="q"
354354+ value="{{ filter_criteria.text_search | default('') }}"
355355+ placeholder="{{ t('filter-search-placeholder') }}">
356356+ </div>
357357+ </div>
358358+359359+ <!-- Date Range -->
360360+ <div class="field">
361361+ <label class="label">{{ t("filter-date-range-label") }}</label>
362362+ <div class="field-body">
363363+ <div class="field">
364364+ <div class="control">
365365+ <input class="input"
366366+ type="date"
367367+ name="start_date"
368368+ value="{{ filter_criteria.start_date | default('') }}">
369369+ </div>
370370+ </div>
371371+ <div class="field">
372372+ <div class="control">
373373+ <input class="input"
374374+ type="date"
375375+ name="end_date"
376376+ value="{{ filter_criteria.end_date | default('') }}">
377377+ </div>
378378+ </div>
379379+ </div>
380380+ </div>
381381+382382+ <!-- Sort Options -->
383383+ <div class="field">
384384+ <label class="label">{{ t("filter-sort-label") }}</label>
385385+ <div class="control">
386386+ <div class="select is-fullwidth">
387387+ <select name="sort">
388388+ <option value="date_asc" {% if filter_criteria.sort_by == "date_asc" %}selected{% endif %}>
389389+ {{ t("filter-sort-date-asc") }}
390390+ </option>
391391+ <option value="date_desc" {% if filter_criteria.sort_by == "date_desc" %}selected{% endif %}>
392392+ {{ t("filter-sort-date-desc") }}
393393+ </option>
394394+ <option value="created_asc" {% if filter_criteria.sort_by == "created_asc" %}selected{% endif %}>
395395+ {{ t("filter-sort-created-asc") }}
396396+ </option>
397397+ <option value="created_desc" {% if filter_criteria.sort_by == "created_desc" %}selected{% endif %}>
398398+ {{ t("filter-sort-created-desc") }}
399399+ </option>
400400+ <option value="relevance" {% if filter_criteria.sort_by == "relevance" %}selected{% endif %}>
401401+ {{ t("filter-sort-relevance") }}
402402+ </option>
403403+ </select>
404404+ </div>
405405+ </div>
406406+ </div>
407407+408408+ <!-- Clear Button -->
409409+ <div class="field">
410410+ <div class="control">
411411+ <a href="/events" class="button is-light is-fullwidth">
412412+ {{ t("filter-clear-all") }}
413413+ </a>
414414+ </div>
415415+ </div>
416416+ </form>
417417+ </div>
418418+419419+ <!-- Dynamic Facets -->
420420+ {% if facets %}
421421+ <div class="box">
422422+ <h3 class="title is-6">{{ t("filter-facets-title") }}</h3>
423423+424424+ <!-- Category Facets -->
425425+ {% if facets.categories %}
426426+ <div class="field">
427427+ <label class="label">{{ t("filter-categories-label") }}</label>
428428+ {% for category in facets.categories %}
429429+ <div class="control">
430430+ <label class="checkbox">
431431+ <input type="checkbox"
432432+ name="categories"
433433+ value="{{ category.value }}"
434434+ {% if category.value in filter_criteria.categories %}checked{% endif %}
435435+ hx-get="/events"
436436+ hx-target="#events-results"
437437+ hx-include="closest form"
438438+ hx-headers='{"HX-Current-Language": "{{ current_locale() }}"}'>
439439+440440+ <!-- Use display_name if available, otherwise translate i18n_key -->
441441+ {% if category.display_name %}
442442+ {{ category.display_name }}
443443+ {% elif category.i18n_key %}
444444+ {{ t(category.i18n_key) }}
445445+ {% else %}
446446+ {{ category.value }}
447447+ {% endif %}
448448+449449+ {{ t("filter-facet-count", count=category.count) }}
450450+ </label>
451451+ </div>
452452+ {% endfor %}
453453+ </div>
454454+ {% endif %}
455455+456456+ <!-- Date Range Facets -->
457457+ {% if facets.date_ranges %}
458458+ <div class="field">
459459+ <label class="label">{{ t("filter-date-ranges-label") }}</label>
460460+ {% for date_range in facets.date_ranges %}
461461+ <div class="control">
462462+ <label class="checkbox">
463463+ <input type="checkbox"
464464+ name="date_ranges"
465465+ value="{{ date_range.value }}"
466466+ {% if date_range.value in filter_criteria.date_ranges %}checked{% endif %}
467467+ hx-get="/events"
468468+ hx-target="#events-results"
469469+ hx-include="closest form"
470470+ hx-headers='{"HX-Current-Language": "{{ current_locale() }}"}'>
471471+472472+ {% if date_range.i18n_key %}
473473+ {{ t(date_range.i18n_key) }}
474474+ {% else %}
475475+ {{ date_range.value }}
476476+ {% endif %}
477477+478478+ {{ t("filter-facet-count", count=date_range.count) }}
479479+ </label>
480480+ </div>
481481+ {% endfor %}
482482+ </div>
483483+ {% endif %}
484484+485485+ <!-- Creator Facets -->
486486+ {% if facets.creators %}
487487+ <div class="field">
488488+ <label class="label">{{ t("filter-creators-label") }}</label>
489489+ {% for creator in facets.creators %}
490490+ <div class="control">
491491+ <label class="checkbox">
492492+ <input type="checkbox"
493493+ name="creators"
494494+ value="{{ creator.value }}"
495495+ {% if creator.value in filter_criteria.creators %}checked{% endif %}
496496+ hx-get="/events"
497497+ hx-target="#events-results"
498498+ hx-include="closest form"
499499+ hx-headers='{"HX-Current-Language": "{{ current_locale() }}"}'>
500500+ {{ creator.value }} {{ t("filter-facet-count", count=creator.count) }}
501501+ </label>
502502+ </div>
503503+ {% endfor %}
504504+ </div>
505505+ {% endif %}
506506+ </div>
507507+ {% endif %}
508508+ </div>
509509+510510+ <!-- Events Results -->
511511+ <div class="column is-9">
512512+ <div id="events-results">
513513+ {% include 'filter_events_results.incl.html' %}
514514+ </div>
515515+ </div>
516516+ </div>
517517+ </div>
518518+</div>
519519+```
520520+521521+#### 5.2 Update Main Filter Pages
522522+Update both main filter pages to use the unified common template:
523523+524524+**File**: `/templates/filter_events.en-us.html`
525525+```html
526526+{% extends "base." + current_locale() + ".html" %}
527527+{% block title %}{{ t("filter-events-title") }}{% endblock %}
528528+{% block head %}
529529+<meta name="description" content="{{ t("filter-events-description") }}">
530530+<meta property="og:title" content="{{ t("filter-events-title") }}">
531531+<meta property="og:description" content="{{ t("filter-events-description") }}">
532532+<meta property="og:site_name" content="{{ t("site-name") }}" />
533533+<meta property="og:type" content="website" />
534534+<meta property="og:url" content="{{ external_base }}/events" />
535535+<script src="https://unpkg.com/htmx.org@1.9.12"></script>
536536+{% endblock %}
537537+{% block content %}
538538+{% include 'filter_events.common.html' %}
539539+{% endblock %}
540540+```
541541+542542+**File**: `/templates/filter_events.fr-ca.html`
543543+```html
544544+{% extends "base." + current_locale() + ".html" %}
545545+{% block title %}{{ t("filter-events-title") }}{% endblock %}
546546+{% block head %}
547547+<meta name="description" content="{{ t("filter-events-description") }}">
548548+<meta property="og:title" content="{{ t("filter-events-title") }}">
549549+<meta property="og:description" content="{{ t("filter-events-description") }}">
550550+<meta property="og:site_name" content="{{ t("site-name") }}" />
551551+<meta property="og:type" content="website" />
552552+<meta property="og:url" content="{{ external_base }}/events" />
553553+<script src="https://unpkg.com/htmx.org@1.9.12"></script>
554554+{% endblock %}
555555+{% block content %}
556556+{% include 'filter_events.common.html' %}
557557+{% endblock %}
558558+```
559559+560560+#### 5.3 Update HTMX Results Template
561561+Create unified results template: `/templates/filter_events_results.incl.html`
562562+563563+```html
564564+<!-- Results Header -->
565565+<div class="level">
566566+ <div class="level-left">
567567+ <div class="level-item">
568568+ <h1 class="title is-4">
569569+ {% if filter_criteria.search_term %}
570570+ {{ t("filter-search-results-for", term=filter_criteria.search_term) }}
571571+ {% else %}
572572+ {{ t("filter-all-events") }}
573573+ {% endif %}
574574+ </h1>
575575+ </div>
576576+ </div>
577577+ <div class="level-right">
578578+ <div class="level-item">
579579+ <p class="subtitle is-6">
580580+ {{ t("filter-results-count", count=total_count) }}
581581+ </p>
582582+ </div>
583583+ </div>
584584+</div>
585585+586586+<!-- Events List -->
587587+{% if events %}
588588+ <div class="columns is-multiline">
589589+ {% for event in events %}
590590+ <div class="column is-half">
591591+ <div class="card">
592592+ <div class="card-content">
593593+ <div class="media">
594594+ <div class="media-content">
595595+ <p class="title is-5">
596596+ <a href="/events/{{ event.rkey }}">{{ event.name }}</a>
597597+ </p>
598598+ <p class="subtitle is-6">
599599+ {% if event.creator_handle %}
600600+ {{ t("event-by-creator", creator=event.creator_handle) }}
601601+ {% endif %}
602602+ </p>
603603+ </div>
604604+ </div>
605605+606606+ <div class="content">
607607+ {% if event.description %}
608608+ <p>{{ event.description | truncate(150) }}</p>
609609+ {% endif %}
610610+611611+ <div class="tags">
612612+ {% if event.start_time %}
613613+ <span class="tag is-info">
614614+ <i class="fas fa-calendar"></i>
615615+ {{ event.start_time | date("Y-m-d H:i") }}
616616+ </span>
617617+ {% endif %}
618618+619619+ {% if event.location %}
620620+ <span class="tag is-success">
621621+ <i class="fas fa-map-marker-alt"></i>
622622+ {{ event.location }}
623623+ </span>
624624+ {% endif %}
625625+626626+ {% if event.rsvp_count %}
627627+ <span class="tag is-warning">
628628+ <i class="fas fa-users"></i>
629629+ {{ t("event-rsvp-count", count=event.rsvp_count) }}
630630+ </span>
631631+ {% endif %}
632632+ </div>
633633+ </div>
634634+ </div>
635635+ </div>
636636+ </div>
637637+ {% endfor %}
638638+ </div>
639639+640640+ <!-- Pagination -->
641641+ {% if has_more_pages %}
642642+ <nav class="pagination is-centered" role="navigation">
643643+ <button class="pagination-previous button"
644644+ hx-get="/events?page={{ current_page - 1 }}"
645645+ hx-target="#events-results"
646646+ hx-include="closest form"
647647+ hx-headers='{"HX-Current-Language": "{{ current_locale() }}"}'>
648648+ {{ t("pagination-previous") }}
649649+ </button>
650650+ <button class="pagination-next button"
651651+ hx-get="/events?page={{ current_page + 1 }}"
652652+ hx-target="#events-results"
653653+ hx-include="closest form"
654654+ hx-headers='{"HX-Current-Language": "{{ current_locale() }}"}'>
655655+ {{ t("pagination-next") }}
656656+ </button>
657657+ </nav>
658658+ {% endif %}
659659+{% else %}
660660+ <div class="notification is-info">
661661+ <p>{{ t("filter-no-results") }}</p>
662662+ <p>{{ t("filter-try-different-criteria") }}</p>
663663+ </div>
664664+{% endif %}
665665+```
666666+667667+### Task 6: Update Route Registration
668668+669669+#### 6.1 Ensure I18n Middleware Integration
670670+**File**: `/src/http/mod.rs` (or main router setup)
671671+672672+```rust
673673+// Ensure filtering routes have i18n middleware
674674+pub fn create_filtering_routes() -> Router<WebContext> {
675675+ Router::new()
676676+ .route("/events", get(handle_filter_events))
677677+ .route("/events/facets", get(handle_filter_facets))
678678+ .route("/events/suggestions", get(handle_filter_suggestions))
679679+ // Middleware stack includes i18n detection
680680+ .layer(from_fn(middleware_i18n::detect_language))
681681+ .layer(from_fn(middleware_filter::extract_filter_params))
682682+}
683683+```
684684+685685+### Task 7: Testing Integration
686686+687687+#### 7.1 Update Existing Tests
688688+**File**: `/src/filtering/facets.rs` - Add i18n tests
689689+690690+```rust
691691+#[cfg(test)]
692692+mod tests {
693693+ use super::*;
694694+ use unic_langid::langid;
695695+696696+ #[test]
697697+ fn test_generate_category_i18n_key() {
698698+ assert_eq!(
699699+ FacetCalculator::generate_category_i18n_key("Technology & Innovation"),
700700+ "category.technology_and_innovation"
701701+ );
702702+703703+ assert_eq!(
704704+ FacetCalculator::generate_category_i18n_key("Arts & Culture"),
705705+ "category.arts_and_culture"
706706+ );
707707+ }
708708+709709+ #[test]
710710+ fn test_get_translated_facet_name() {
711711+ let calculator = FacetCalculator::new(/* mock pool */);
712712+ let locale = langid!("en-US");
713713+714714+ // Test with existing translation key
715715+ let translated = calculator.get_translated_facet_name(
716716+ "category.technology_and_innovation",
717717+ &locale
718718+ );
719719+720720+ // Should return translated text if key exists, otherwise formatted fallback
721721+ assert!(!translated.is_empty());
722722+ }
723723+724724+ #[sqlx::test]
725725+ async fn test_facet_calculation_with_locale() {
726726+ // Integration test with real database
727727+ let pool = setup_test_db().await;
728728+ let calculator = FacetCalculator::new(pool);
729729+ let criteria = EventFilterCriteria::default();
730730+ let locale = langid!("en-US");
731731+732732+ let facets = calculator
733733+ .calculate_facets_with_locale(&criteria, &locale)
734734+ .await
735735+ .unwrap();
736736+737737+ // Verify facets have proper i18n keys and display names
738738+ for category in &facets.categories {
739739+ assert!(category.i18n_key.is_some());
740740+ assert!(category.display_name.is_some());
741741+ }
742742+ }
743743+}
744744+```
745745+746746+#### 7.2 Add I18n Template Tests
747747+**File**: `/tests/integration/filter_i18n_test.rs`
748748+749749+```rust
750750+#[tokio::test]
751751+async fn test_filter_page_renders_with_locale() {
752752+ let app = create_test_app().await;
753753+754754+ // Test English
755755+ let response = app
756756+ .oneshot(
757757+ Request::builder()
758758+ .uri("/events")
759759+ .header("Accept-Language", "en-US,en;q=0.9")
760760+ .body(Body::empty())
761761+ .unwrap(),
762762+ )
763763+ .await
764764+ .unwrap();
765765+766766+ assert_eq!(response.status(), StatusCode::OK);
767767+ let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
768768+ let html = String::from_utf8(body.to_vec()).unwrap();
769769+770770+ // Verify English translations are used
771771+ assert!(html.contains("Filter Options"));
772772+ assert!(html.contains("Categories"));
773773+774774+ // Test French
775775+ let response = app
776776+ .oneshot(
777777+ Request::builder()
778778+ .uri("/events")
779779+ .header("Accept-Language", "fr-CA,fr;q=0.9")
780780+ .body(Body::empty())
781781+ .unwrap(),
782782+ )
783783+ .await
784784+ .unwrap();
785785+786786+ let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
787787+ let html = String::from_utf8(body.to_vec()).unwrap();
788788+789789+ // Verify French translations are used
790790+ assert!(html.contains("Options de filtrage"));
791791+ assert!(html.contains("Catégories"));
792792+}
793793+794794+#[tokio::test]
795795+async fn test_htmx_facet_updates_preserve_language() {
796796+ let app = create_test_app().await;
797797+798798+ let response = app
799799+ .oneshot(
800800+ Request::builder()
801801+ .uri("/events")
802802+ .method("GET")
803803+ .header("HX-Request", "true")
804804+ .header("HX-Current-Language", "fr-ca")
805805+ .body(Body::empty())
806806+ .unwrap(),
807807+ )
808808+ .await
809809+ .unwrap();
810810+811811+ assert_eq!(response.status(), StatusCode::OK);
812812+813813+ // Verify HTMX response includes proper language context
814814+ let headers = response.headers();
815815+ assert!(headers.contains_key("HX-Language"));
816816+}
817817+```
818818+819819+---
820820+821821+## Implementation Order
822822+823823+### Phase 4A: Foundation (Day 1)
824824+1. **Add translation keys** to both language files
825825+2. **Update FacetValue structure** with display_name field
826826+3. **Extend FacetCalculator** with locale-aware methods
827827+828828+### Phase 4B: Service Integration (Day 1-2)
829829+1. **Update FilteringService** to accept locale parameter
830830+2. **Modify HTTP handlers** to extract and use locale
831831+3. **Update cache keys** to include locale for proper separation
832832+833833+### Phase 4C: Template Migration (Day 2)
834834+1. **Create unified templates** using i18n functions
835835+2. **Remove language-specific .common.html files**
836836+3. **Update HTMX result templates** with proper language headers
837837+838838+### Phase 4D: Testing & Validation (Day 2-3)
839839+1. **Run existing test suite** to ensure no regressions
840840+2. **Add i18n-specific tests** for facet calculation and template rendering
841841+3. **Manual testing** of language switching and HTMX updates
842842+4. **Performance validation** with locale-aware caching
843843+844844+---
845845+846846+## Expected Outcomes
847847+848848+### ✅ Features Delivered
849849+850850+1. **Locale-Aware Facets**: Category and date range facets display in user's language
851851+2. **Dynamic Template Functions**: All filter templates use `t()` functions instead of hardcoded text
852852+3. **HTMX Language Propagation**: Partial updates maintain language context
853853+4. **Intelligent Fallbacks**: Graceful degradation when translations missing
854854+5. **Cache Efficiency**: Locale-specific caching without excessive memory usage
855855+856856+### 🚀 Performance Benefits
857857+858858+- **Zero Runtime Translation Overhead**: fluent-templates static loading
859859+- **Intelligent Caching**: Locale-aware cache keys prevent cross-language contamination
860860+- **Efficient Facet Calculation**: Pre-computed display names reduce template complexity
861861+- **HTMX Optimization**: Language headers minimize full page reloads
862862+863863+### 🧪 Quality Assurance
864864+865865+- **Comprehensive Test Coverage**: Unit, integration, and i18n-specific tests
866866+- **Translation Validation**: Automated checking for missing keys
867867+- **Manual Testing**: Cross-language functionality validation
868868+- **Performance Monitoring**: Cache hit rates and response times
869869+870870+---
871871+872872+## Risk Mitigation
873873+874874+### Potential Issues
875875+876876+1. **Cache Key Explosion**: Including locale in cache keys increases memory usage
877877+ - **Solution**: Implement cache size limits and intelligent eviction
878878+879879+2. **Translation Key Mismatches**: Category names may not match translation keys
880880+ - **Solution**: Fallback logic to generate readable names from keys
881881+882882+3. **Template Complexity**: Unified templates may be harder to debug
883883+ - **Solution**: Maintain clear separation and good commenting
884884+885885+### Rollback Plan
886886+887887+- **Feature Flag**: Implement `enable_filter_i18n` flag for quick disable
888888+- **Template Fallback**: Keep original language-specific templates as backup
889889+- **Cache Compatibility**: Ensure new cache keys don't break existing cache
890890+891891+---
892892+893893+## Conclusion
894894+895895+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.
896896+897897+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
···315315filter-per-page = per page
316316filter-loading = Loading events...
317317filter-facets-title = Filter Options
318318-filter-categories-label = Categories
318318+filter-modes-label = Event Type
319319+filter-statuses-label = Event Status
319320filter-creators-label = Event Organizers
320321filter-results-title = Events Found
321322filter-no-results-title = No events found
322323filter-no-results-message = Try adjusting your search criteria or location filters.
323324325325+# Event Mode Labels
326326+community-lexicon-calendar-event-mode-inperson = In Person
327327+community-lexicon-calendar-event-mode-virtual = Virtual
328328+community-lexicon-calendar-event-mode-hybrid = Hybrid
329329+330330+# Event Status Labels
331331+community-lexicon-calendar-event-status-scheduled = Scheduled
332332+community-lexicon-calendar-event-status-rescheduled = Rescheduled
333333+community-lexicon-calendar-event-status-cancelled = Cancelled
334334+community-lexicon-calendar-event-status-postponed = Postponed
335335+community-lexicon-calendar-event-status-planned = Planned
336336+324337# Pagination
325338pagination-previous = Previous
326339pagination-next = Next
···337350# Event details
338351event-starts = Starts
339352event-location = Location
353353+354354+# Event Category Facets
355355+category-arts-and-culture = Arts & Culture
356356+category-business-and-networking = Business & Networking
357357+category-community-and-social = Community & Social
358358+category-education-and-learning = Education & Learning
359359+category-family-and-kids = Family & Kids
360360+category-food-and-drink = Food & Drink
361361+category-health-and-wellness = Health & Wellness
362362+category-music-and-entertainment = Music & Entertainment
363363+category-outdoor-and-recreation = Outdoor & Recreation
364364+category-politics-and-activism = Politics & Activism
365365+category-religion-and-spirituality = Religion & Spirituality
366366+category-sports-and-fitness = Sports & Fitness
367367+category-technology-and-innovation = Technology & Innovation
368368+category-travel-and-adventure = Travel & Adventure
369369+category-volunteer-and-charity = Volunteer & Charity
370370+371371+# Date Range Facets
372372+date-range-today = Today
373373+date-range-this-week = This Week
374374+date-range-this-month = This Month
375375+date-range-next-week = Next Week
376376+date-range-next-month = Next Month
+38-1
i18n/fr-ca/ui.ftl
···315315filter-per-page = par page
316316filter-loading = Chargement des événements...
317317filter-facets-title = Options de filtrage
318318-filter-categories-label = Catégories
318318+filter-modes-label = Type d'événement
319319+filter-statuses-label = Statut de l'événement
319320filter-creators-label = Organisateurs d'événements
320321filter-results-title = Événements trouvés
321322filter-no-results-title = Aucun événement trouvé
322323filter-no-results-message = Essayez d'ajuster vos critères de recherche ou filtres de lieu.
323324325325+# Étiquettes de mode d'événement
326326+community-lexicon-calendar-event-mode-inperson = En personne
327327+community-lexicon-calendar-event-mode-virtual = Virtuel
328328+community-lexicon-calendar-event-mode-hybrid = Hybride
329329+330330+# Étiquettes de statut d'événement
331331+community-lexicon-calendar-event-status-scheduled = Programmé
332332+community-lexicon-calendar-event-status-rescheduled = Reprogrammé
333333+community-lexicon-calendar-event-status-cancelled = Annulé
334334+community-lexicon-calendar-event-status-postponed = Reporté
335335+community-lexicon-calendar-event-status-planned = Planifié
336336+324337# Pagination
325338pagination-previous = Précédent
326339pagination-next = Suivant
···337350# Détails de l'événement
338351event-starts = Commence
339352event-location = Lieu
353353+354354+# Facettes de catégories d'événements
355355+category-arts-and-culture = Arts et culture
356356+category-business-and-networking = Affaires et réseautage
357357+category-community-and-social = Communauté et social
358358+category-education-and-learning = Éducation et apprentissage
359359+category-family-and-kids = Famille et enfants
360360+category-food-and-drink = Nourriture et boissons
361361+category-health-and-wellness = Santé et bien-être
362362+category-music-and-entertainment = Musique et divertissement
363363+category-outdoor-and-recreation = Plein air et loisirs
364364+category-politics-and-activism = Politique et activisme
365365+category-religion-and-spirituality = Religion et spiritualité
366366+category-sports-and-fitness = Sports et conditionnement physique
367367+category-technology-and-innovation = Technologie et innovation
368368+category-travel-and-adventure = Voyage et aventure
369369+category-volunteer-and-charity = Bénévolat et charité
370370+371371+# Facettes de plages de dates
372372+date-range-today = Aujourd'hui
373373+date-range-this-week = Cette semaine
374374+date-range-this-month = Ce mois-ci
375375+date-range-next-week = La semaine prochaine
376376+date-range-next-month = Le mois prochain
···99use chrono::Datelike;
10101111use super::{EventFilterCriteria, FilterError};
1212+use crate::atproto::lexicon::community::lexicon::calendar::event::{Mode, Status};
12131314/// Represents a single facet value with its count
1415#[derive(Debug, Clone, Serialize, Deserialize)]
···2122/// Collection of facets for different dimensions
2223#[derive(Debug, Clone, Serialize, Deserialize, Default)]
2324pub struct EventFacets {
2424- pub categories: Vec<FacetValue>,
2525+ pub modes: Vec<FacetValue>,
2626+ pub statuses: Vec<FacetValue>,
2527 pub creators: Vec<FacetValue>,
2628 pub date_ranges: Vec<FacetValue>,
2729 pub total_count: i64,
···5052 // Calculate total count
5153 facets.total_count = self.calculate_total_count(criteria).await?;
52545353- // Calculate category facets
5454- facets.categories = self.calculate_category_facets(criteria).await?;
5555+ // Calculate mode facets
5656+ facets.modes = self.calculate_mode_facets(criteria).await?;
5757+5858+ // Calculate status facets
5959+ facets.statuses = self.calculate_status_facets(criteria).await?;
55605661 // Calculate creator facets
5762 facets.creators = self.calculate_creator_facets(criteria).await?;
···7782 Ok(count.0)
7883 }
79848080- /// Calculate category facets
8181- async fn calculate_category_facets(
8585+ /// Calculate mode facets
8686+ async fn calculate_mode_facets(
8287 &self,
8388 criteria: &EventFilterCriteria,
8489 ) -> Result<Vec<FacetValue>, FilterError> {
8590 let mut query = QueryBuilder::new(
8686- "SELECT jsonb_array_elements_text(record->'categories') as category, COUNT(*) as count
8787- FROM events"
9191+ "SELECT record->>'mode' as mode, COUNT(*) as count FROM events"
8892 );
89939090- // Apply filters excluding categories to show all available categories
9191- let criteria_without_categories = EventFilterCriteria {
9292- categories: vec![], // Exclude category filters for facet calculation
9494+ // Apply filters excluding modes to show all available modes
9595+ let criteria_without_modes = EventFilterCriteria {
9696+ modes: vec![], // Exclude mode filters for facet calculation
9397 ..criteria.clone()
9498 };
9595- self.apply_base_filters(&mut query, &criteria_without_categories);
9999+ self.apply_base_filters(&mut query, &criteria_without_modes);
961009797- query.push(" AND record->'categories' IS NOT NULL");
9898- query.push(" GROUP BY category");
9999- query.push(" ORDER BY count DESC, category ASC");
100100- query.push(" LIMIT 50"); // Limit facet results
101101+ query.push(" AND record->>'mode' IS NOT NULL");
102102+ query.push(" GROUP BY mode");
103103+ query.push(" ORDER BY count DESC, mode ASC");
101104102105 let rows = query
103106 .build()
···106109107110 let mut facets = Vec::new();
108111 for row in rows {
109109- let category: String = row.try_get("category")?;
112112+ let mode_str: Option<String> = row.try_get("mode")?;
110113 let count: i64 = row.try_get("count")?;
111114112112- facets.push(FacetValue {
113113- i18n_key: Some(Self::generate_category_i18n_key(&category)),
114114- value: category,
115115- count,
116116- });
115115+ if let Some(mode_str) = mode_str {
116116+ facets.push(FacetValue {
117117+ value: mode_str.clone(),
118118+ count,
119119+ i18n_key: Some(Self::generate_mode_i18n_key(&mode_str)),
120120+ });
121121+ }
122122+ }
123123+124124+ Ok(facets)
125125+ }
126126+127127+ /// Calculate status facets
128128+ async fn calculate_status_facets(
129129+ &self,
130130+ criteria: &EventFilterCriteria,
131131+ ) -> Result<Vec<FacetValue>, FilterError> {
132132+ let mut query = QueryBuilder::new(
133133+ "SELECT record->>'status' as status, COUNT(*) as count FROM events"
134134+ );
135135+136136+ // Apply filters excluding statuses to show all available statuses
137137+ let criteria_without_statuses = EventFilterCriteria {
138138+ statuses: vec![], // Exclude status filters for facet calculation
139139+ ..criteria.clone()
140140+ };
141141+ self.apply_base_filters(&mut query, &criteria_without_statuses);
142142+143143+ query.push(" AND record->>'status' IS NOT NULL");
144144+ query.push(" GROUP BY status");
145145+ query.push(" ORDER BY count DESC, status ASC");
146146+147147+ let rows = query
148148+ .build()
149149+ .fetch_all(&self.pool)
150150+ .await?;
151151+152152+ let mut facets = Vec::new();
153153+ for row in rows {
154154+ let status_str: Option<String> = row.try_get("status")?;
155155+ let count: i64 = row.try_get("count")?;
156156+157157+ if let Some(status_str) = status_str {
158158+ facets.push(FacetValue {
159159+ value: status_str.clone(),
160160+ count,
161161+ i18n_key: Some(Self::generate_status_i18n_key(&status_str)),
162162+ });
163163+ }
117164 }
118165119166 Ok(facets)
···181228 facets.push(FacetValue {
182229 value: key.to_string(),
183230 count,
184184- i18n_key: Some(format!("date_range.{}", key)),
231231+ i18n_key: Some(format!("date-range-{}", key)),
185232 });
186233 }
187234 }
···310357 has_where = true;
311358 }
312359313313- // Category filtering
314314- if !criteria.categories.is_empty() {
360360+ // Mode filtering
361361+ if !criteria.modes.is_empty() {
315362 query.push(if has_where { " AND " } else { " WHERE " });
316363 query.push("(");
317317- for (i, category) in criteria.categories.iter().enumerate() {
364364+ for (i, mode) in criteria.modes.iter().enumerate() {
318365 if i > 0 {
319366 query.push(" OR ");
320367 }
321321- query.push("record->'categories' ? ");
322322- query.push_bind(category);
368368+ // Convert Mode enum to its string representation
369369+ let mode_str = match mode {
370370+ Mode::InPerson => "community.lexicon.calendar.event#inperson",
371371+ Mode::Virtual => "community.lexicon.calendar.event#virtual",
372372+ Mode::Hybrid => "community.lexicon.calendar.event#hybrid",
373373+ };
374374+ query.push("record->>'mode' = ");
375375+ query.push_bind(mode_str);
376376+ }
377377+ query.push(")");
378378+ has_where = true;
379379+ }
380380+381381+ // Status filtering
382382+ if !criteria.statuses.is_empty() {
383383+ query.push(if has_where { " AND " } else { " WHERE " });
384384+ query.push("(");
385385+ for (i, status) in criteria.statuses.iter().enumerate() {
386386+ if i > 0 {
387387+ query.push(" OR ");
388388+ }
389389+ // Convert Status enum to its string representation
390390+ let status_str = match status {
391391+ Status::Scheduled => "community.lexicon.calendar.event#scheduled",
392392+ Status::Rescheduled => "community.lexicon.calendar.event#rescheduled",
393393+ Status::Cancelled => "community.lexicon.calendar.event#cancelled",
394394+ Status::Postponed => "community.lexicon.calendar.event#postponed",
395395+ Status::Planned => "community.lexicon.calendar.event#planned",
396396+ };
397397+ query.push("record->>'status' = ");
398398+ query.push_bind(status_str);
323399 }
324400 query.push(")");
325401 has_where = true;
···343419 }
344420 }
345421346346- /// Generate i18n key for category facets
347347- fn generate_category_i18n_key(category: &str) -> String {
348348- format!("category.{}",
349349- category.to_lowercase()
350350- .replace(" ", "_")
351351- .replace("&", "and")
352352- .chars()
353353- .filter(|c| c.is_alphanumeric() || *c == '_')
354354- .collect::<String>()
355355- )
422422+ /// Generate i18n key for mode facets
423423+ fn generate_mode_i18n_key(mode: &str) -> String {
424424+ match mode {
425425+ "community.lexicon.calendar.event#inperson" => "mode-inperson".to_string(),
426426+ "community.lexicon.calendar.event#virtual" => "mode-virtual".to_string(),
427427+ "community.lexicon.calendar.event#hybrid" => "mode-hybrid".to_string(),
428428+ _ => format!("mode-{}", mode.to_lowercase().replace('#', "-").replace('.', "-")),
429429+ }
430430+ }
431431+432432+ /// Generate i18n key for status facets
433433+ fn generate_status_i18n_key(status: &str) -> String {
434434+ match status {
435435+ "community.lexicon.calendar.event#scheduled" => "status-scheduled".to_string(),
436436+ "community.lexicon.calendar.event#rescheduled" => "status-rescheduled".to_string(),
437437+ "community.lexicon.calendar.event#cancelled" => "status-cancelled".to_string(),
438438+ "community.lexicon.calendar.event#postponed" => "status-postponed".to_string(),
439439+ "community.lexicon.calendar.event#planned" => "status-planned".to_string(),
440440+ _ => format!("status-{}", status.to_lowercase().replace('#', "-").replace('.', "-")),
441441+ }
356442 }
357443}
358444···361447 use super::*;
362448363449 #[test]
364364- fn test_generate_category_i18n_key() {
450450+ fn test_generate_mode_i18n_key() {
365451 assert_eq!(
366366- FacetCalculator::generate_category_i18n_key("Technology & Innovation"),
367367- "category.technology_and_innovation"
452452+ FacetCalculator::generate_mode_i18n_key("community.lexicon.calendar.event#inperson"),
453453+ "mode-inperson"
368454 );
369455370456 assert_eq!(
371371- FacetCalculator::generate_category_i18n_key("Arts & Culture"),
372372- "category.arts_and_culture"
457457+ FacetCalculator::generate_mode_i18n_key("community.lexicon.calendar.event#virtual"),
458458+ "mode-virtual"
459459+ );
460460+ }
461461+462462+ #[test]
463463+ fn test_generate_status_i18n_key() {
464464+ assert_eq!(
465465+ FacetCalculator::generate_status_i18n_key("community.lexicon.calendar.event#scheduled"),
466466+ "status-scheduled"
467467+ );
468468+469469+ assert_eq!(
470470+ FacetCalculator::generate_status_i18n_key("community.lexicon.calendar.event#cancelled"),
471471+ "status-cancelled"
373472 );
374473 }
375474}
+3
src/filtering/mod.rs
···1313#[cfg(test)]
1414mod cache_integration_test;
15151616+#[cfg(test)]
1717+mod mode_status_integration_test;
1818+1619pub use criteria::*;
1720pub use errors::*;
1821pub use facets::*;
+91
src/filtering/mode_status_integration_test.rs
···11+// Integration test for mode and status filtering
22+//
33+// Tests the complete filtering pipeline from HTTP parameters to SQL queries
44+55+#[cfg(test)]
66+mod tests {
77+ use crate::filtering::{EventFilterCriteria, EventFacets, FacetValue};
88+ use crate::atproto::lexicon::community::lexicon::calendar::event::{Mode, Status};
99+1010+ #[test]
1111+ fn test_mode_status_filtering_pipeline() {
1212+ // Test direct criteria building with modes and statuses
1313+ let criteria = EventFilterCriteria::new()
1414+ .with_mode(Mode::InPerson)
1515+ .with_mode(Mode::Virtual)
1616+ .with_status(Status::Scheduled)
1717+ .with_status(Status::Cancelled);
1818+1919+ assert_eq!(criteria.modes.len(), 2);
2020+ assert!(criteria.modes.contains(&Mode::InPerson));
2121+ assert!(criteria.modes.contains(&Mode::Virtual));
2222+2323+ assert_eq!(criteria.statuses.len(), 2);
2424+ assert!(criteria.statuses.contains(&Status::Scheduled));
2525+ assert!(criteria.statuses.contains(&Status::Cancelled));
2626+ }
2727+2828+ #[test]
2929+ fn test_mode_status_facet_generation() {
3030+ // Test that facets can be generated for modes and statuses
3131+ let modes = vec![
3232+ FacetValue {
3333+ value: "inperson".to_string(),
3434+ count: 5,
3535+ i18n_key: Some("community-lexicon-calendar-event-mode-inperson".to_string())
3636+ },
3737+ FacetValue {
3838+ value: "virtual".to_string(),
3939+ count: 3,
4040+ i18n_key: Some("community-lexicon-calendar-event-mode-virtual".to_string())
4141+ },
4242+ ];
4343+4444+ let statuses = vec![
4545+ FacetValue {
4646+ value: "scheduled".to_string(),
4747+ count: 8,
4848+ i18n_key: Some("community-lexicon-calendar-event-status-scheduled".to_string())
4949+ },
5050+ ];
5151+5252+ let facets = EventFacets {
5353+ modes,
5454+ statuses,
5555+ ..Default::default()
5656+ };
5757+5858+ assert_eq!(facets.modes.len(), 2);
5959+ assert_eq!(facets.statuses.len(), 1);
6060+ assert_eq!(facets.modes[0].count, 5);
6161+ assert_eq!(facets.statuses[0].count, 8);
6262+ }
6363+6464+ #[test]
6565+ fn test_criteria_has_filters() {
6666+ // Test that criteria correctly reports when filters are active
6767+ let empty_criteria = EventFilterCriteria::new();
6868+ assert!(!empty_criteria.has_filters());
6969+7070+ let mode_criteria = EventFilterCriteria::new().with_mode(Mode::InPerson);
7171+ assert!(mode_criteria.has_filters());
7272+7373+ let status_criteria = EventFilterCriteria::new().with_status(Status::Scheduled);
7474+ assert!(status_criteria.has_filters());
7575+ }
7676+7777+ #[test]
7878+ fn test_cache_key_includes_modes_and_statuses() {
7979+ // Test that cache keys include mode and status information
8080+ let criteria1 = EventFilterCriteria::new()
8181+ .with_mode(Mode::InPerson)
8282+ .with_status(Status::Scheduled);
8383+8484+ let criteria2 = EventFilterCriteria::new()
8585+ .with_mode(Mode::Virtual)
8686+ .with_status(Status::Scheduled);
8787+8888+ // Different modes should result in different cache keys
8989+ assert_ne!(criteria1.cache_hash(), criteria2.cache_hash());
9090+ }
9191+}
+43-5
src/filtering/query_builder.rs
···8899use super::{EventFilterCriteria, EventSortField, FilterError, LocationFilter, SortOrder};
1010use crate::storage::event::model::Event;
1111+use crate::atproto::lexicon::community::lexicon::calendar::event::{Mode, Status};
11121213/// SQL query builder for event filtering
1314#[derive(Debug, Clone)]
···120121 has_where = true;
121122 }
122123123123- // Category filtering
124124- if !criteria.categories.is_empty() {
124124+ // Mode filtering
125125+ if !criteria.modes.is_empty() {
126126+ query.push(if has_where { " AND " } else { " WHERE " });
127127+ query.push("(");
128128+ for (i, mode) in criteria.modes.iter().enumerate() {
129129+ if i > 0 {
130130+ query.push(" OR ");
131131+ }
132132+ // Convert enum to its serde representation for database comparison
133133+ let mode_str = match mode {
134134+ Mode::InPerson =>
135135+ "community.lexicon.calendar.event#inperson",
136136+ Mode::Virtual =>
137137+ "community.lexicon.calendar.event#virtual",
138138+ Mode::Hybrid =>
139139+ "community.lexicon.calendar.event#hybrid",
140140+ };
141141+ query.push("record->>'mode' = ");
142142+ query.push_bind(mode_str);
143143+ }
144144+ query.push(")");
145145+ has_where = true;
146146+ }
147147+148148+ // Status filtering
149149+ if !criteria.statuses.is_empty() {
125150 query.push(if has_where { " AND " } else { " WHERE " });
126151 query.push("(");
127127- for (i, category) in criteria.categories.iter().enumerate() {
152152+ for (i, status) in criteria.statuses.iter().enumerate() {
128153 if i > 0 {
129154 query.push(" OR ");
130155 }
131131- query.push("record->'categories' ? ");
132132- query.push_bind(category);
156156+ // Convert enum to its serde representation for database comparison
157157+ let status_str = match status {
158158+ Status::Scheduled =>
159159+ "community.lexicon.calendar.event#scheduled",
160160+ Status::Rescheduled =>
161161+ "community.lexicon.calendar.event#rescheduled",
162162+ Status::Cancelled =>
163163+ "community.lexicon.calendar.event#cancelled",
164164+ Status::Postponed =>
165165+ "community.lexicon.calendar.event#postponed",
166166+ Status::Planned =>
167167+ "community.lexicon.calendar.event#planned",
168168+ };
169169+ query.push("record->>'status' = ");
170170+ query.push_bind(status_str);
133171 }
134172 query.push(")");
135173 has_where = true;