forked from
smokesignal.events/smokesignal
i18n+filtering fork - fluent-templates v2
1// Facet calculation logic for event filtering
2//
3// Calculates facets (aggregated counts) for different filter dimensions,
4// providing users with insight into available filtering options.
5
6use sqlx::{PgPool, QueryBuilder, Row};
7use serde::{Deserialize, Serialize};
8use tracing::instrument;
9use chrono::Datelike;
10use unic_langid::LanguageIdentifier;
11use fluent_templates::Loader;
12
13use super::{EventFilterCriteria, FilterError};
14use crate::atproto::lexicon::community::lexicon::calendar::event::{Mode, Status};
15use crate::storage::handle::handles_by_did;
16
17/// Represents a single facet value with its count
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct FacetValue {
20 pub value: String,
21 pub count: i64,
22 pub i18n_key: Option<String>,
23 /// Pre-calculated display name (locale-specific)
24 pub display_name: Option<String>,
25}
26
27/// Collection of facets for different dimensions
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct EventFacets {
30 pub modes: Vec<FacetValue>,
31 pub statuses: Vec<FacetValue>,
32 pub creators: Vec<FacetValue>,
33 pub date_ranges: Vec<FacetValue>,
34 pub total_count: i64,
35}
36
37/// Facet calculator for events
38#[derive(Debug, Clone)]
39pub struct FacetCalculator {
40 pool: PgPool,
41}
42
43impl FacetCalculator {
44 /// Create a new facet calculator
45 pub fn new(pool: PgPool) -> Self {
46 Self { pool }
47 }
48
49 /// Calculate all facets for the given filter criteria
50 #[instrument(skip(self, criteria))]
51 pub async fn calculate_facets(
52 &self,
53 criteria: &EventFilterCriteria,
54 ) -> Result<EventFacets, FilterError> {
55 let mut facets = EventFacets::default();
56
57 // Calculate total count
58 facets.total_count = self.calculate_total_count(criteria).await?;
59
60 // Calculate mode facets
61 facets.modes = self.calculate_mode_facets(criteria).await?;
62
63 // Calculate status facets
64 facets.statuses = self.calculate_status_facets(criteria).await?;
65
66 // Calculate creator facets
67 facets.creators = self.calculate_creator_facets(criteria).await?;
68
69 // Calculate date range facets
70 facets.date_ranges = self.calculate_date_range_facets(criteria).await?;
71
72 Ok(facets)
73 }
74
75 /// Calculate all facets for the given filter criteria with locale support
76 #[instrument(skip(self, criteria))]
77 pub async fn calculate_facets_with_locale(
78 &self,
79 criteria: &EventFilterCriteria,
80 locale: &LanguageIdentifier,
81 ) -> Result<EventFacets, FilterError> {
82 let mut facets = EventFacets::default();
83
84 // Calculate total count
85 facets.total_count = self.calculate_total_count(criteria).await?;
86
87 // Calculate mode facets with i18n support
88 facets.modes = self.calculate_mode_facets_with_locale(criteria, locale).await?;
89
90 // Calculate status facets with i18n support
91 facets.statuses = self.calculate_status_facets_with_locale(criteria, locale).await?;
92
93 // Calculate creator facets
94 facets.creators = self.calculate_creator_facets(criteria).await?;
95
96 // Calculate date range facets with i18n support
97 facets.date_ranges = self.calculate_date_range_facets_with_locale(criteria, locale).await?;
98
99 Ok(facets)
100 }
101
102 /// Calculate total count of events matching the criteria
103 async fn calculate_total_count(&self, criteria: &EventFilterCriteria) -> Result<i64, FilterError> {
104 let mut query = QueryBuilder::new("SELECT COUNT(*) FROM events");
105
106 // Apply same WHERE conditions as main query but exclude the specific facet being calculated
107 self.apply_base_filters(&mut query, criteria);
108
109 let count: (i64,) = query
110 .build_query_as()
111 .fetch_one(&self.pool)
112 .await?;
113
114 Ok(count.0)
115 }
116
117 /// Calculate mode facets
118 async fn calculate_mode_facets(
119 &self,
120 criteria: &EventFilterCriteria,
121 ) -> Result<Vec<FacetValue>, FilterError> {
122 let mut query = QueryBuilder::new(
123 "SELECT record->>'mode' as mode, COUNT(*) as count FROM events"
124 );
125
126 // Apply filters excluding modes to show all available modes
127 let criteria_without_modes = EventFilterCriteria {
128 modes: vec![], // Exclude mode filters for facet calculation
129 ..criteria.clone()
130 };
131 let has_where = self.apply_base_filters(&mut query, &criteria_without_modes);
132
133 query.push(if has_where { " AND " } else { " WHERE " });
134 query.push("record->>'mode' IS NOT NULL");
135 query.push(" GROUP BY mode");
136 query.push(" ORDER BY count DESC, mode ASC");
137
138 let rows = query
139 .build()
140 .fetch_all(&self.pool)
141 .await?;
142
143 let mut facets = Vec::new();
144 for row in rows {
145 let mode_str: Option<String> = row.try_get("mode")?;
146 let count: i64 = row.try_get("count")?;
147
148 if let Some(mode_str) = mode_str {
149 facets.push(FacetValue {
150 value: mode_str.clone(),
151 count,
152 i18n_key: Some(Self::generate_mode_i18n_key(&mode_str)),
153 display_name: None,
154 });
155 }
156 }
157
158 Ok(facets)
159 }
160
161 /// Calculate mode facets with locale support
162 pub async fn calculate_mode_facets_with_locale(
163 &self,
164 criteria: &EventFilterCriteria,
165 locale: &LanguageIdentifier,
166 ) -> Result<Vec<FacetValue>, FilterError> {
167 let mut query = QueryBuilder::new(
168 "SELECT record->>'mode' as mode, COUNT(*) as count FROM events"
169 );
170
171 // Apply filters excluding modes to show all available modes
172 let criteria_without_modes = EventFilterCriteria {
173 modes: vec![], // Exclude mode filters for facet calculation
174 ..criteria.clone()
175 };
176 let has_where = self.apply_base_filters(&mut query, &criteria_without_modes);
177
178 query.push(if has_where { " AND " } else { " WHERE " });
179 query.push("record->>'mode' IS NOT NULL");
180 query.push(" GROUP BY mode");
181 query.push(" ORDER BY count DESC, mode ASC");
182
183 let rows = query
184 .build()
185 .fetch_all(&self.pool)
186 .await?;
187
188 let mut facets = Vec::new();
189 for row in rows {
190 let mode_str: Option<String> = row.try_get("mode")?;
191 let count: i64 = row.try_get("count")?;
192
193 if let Some(mode_str) = mode_str {
194 let i18n_key = Self::generate_mode_i18n_key(&mode_str);
195 facets.push(FacetValue {
196 value: mode_str.clone(),
197 count,
198 i18n_key: Some(i18n_key.clone()),
199 display_name: Some(self.get_translated_facet_name(&i18n_key, locale)),
200 });
201 }
202 }
203
204 Ok(facets)
205 }
206
207 /// Calculate status facets
208 async fn calculate_status_facets(
209 &self,
210 criteria: &EventFilterCriteria,
211 ) -> Result<Vec<FacetValue>, FilterError> {
212 let mut query = QueryBuilder::new(
213 "SELECT record->>'status' as status, COUNT(*) as count FROM events"
214 );
215
216 // Apply filters excluding statuses to show all available statuses
217 let criteria_without_statuses = EventFilterCriteria {
218 statuses: vec![], // Exclude status filters for facet calculation
219 ..criteria.clone()
220 };
221 let has_where = self.apply_base_filters(&mut query, &criteria_without_statuses);
222
223 query.push(if has_where { " AND " } else { " WHERE " });
224 query.push("record->>'status' IS NOT NULL");
225 query.push(" GROUP BY status");
226 query.push(" ORDER BY count DESC, status ASC");
227
228 let rows = query
229 .build()
230 .fetch_all(&self.pool)
231 .await?;
232
233 let mut facets = Vec::new();
234 for row in rows {
235 let status_str: Option<String> = row.try_get("status")?;
236 let count: i64 = row.try_get("count")?;
237
238 if let Some(status_str) = status_str {
239 facets.push(FacetValue {
240 value: status_str.clone(),
241 count,
242 i18n_key: Some(Self::generate_status_i18n_key(&status_str)),
243 display_name: None,
244 });
245 }
246 }
247
248 Ok(facets)
249 }
250
251 /// Calculate status facets with locale support
252 pub async fn calculate_status_facets_with_locale(
253 &self,
254 criteria: &EventFilterCriteria,
255 locale: &LanguageIdentifier,
256 ) -> Result<Vec<FacetValue>, FilterError> {
257 let mut query = QueryBuilder::new(
258 "SELECT record->>'status' as status, COUNT(*) as count FROM events"
259 );
260
261 // Apply filters excluding statuses to show all available statuses
262 let criteria_without_statuses = EventFilterCriteria {
263 statuses: vec![], // Exclude status filters for facet calculation
264 ..criteria.clone()
265 };
266 let has_where = self.apply_base_filters(&mut query, &criteria_without_statuses);
267
268 query.push(if has_where { " AND " } else { " WHERE " });
269 query.push("record->>'status' IS NOT NULL");
270 query.push(" GROUP BY status");
271 query.push(" ORDER BY count DESC, status ASC");
272
273 let rows = query
274 .build()
275 .fetch_all(&self.pool)
276 .await?;
277
278 let mut facets = Vec::new();
279 for row in rows {
280 let status_str: Option<String> = row.try_get("status")?;
281 let count: i64 = row.try_get("count")?;
282
283 if let Some(status_str) = status_str {
284 let i18n_key = Self::generate_status_i18n_key(&status_str);
285 facets.push(FacetValue {
286 value: status_str.clone(),
287 count,
288 i18n_key: Some(i18n_key.clone()),
289 display_name: Some(self.get_translated_facet_name(&i18n_key, locale)),
290 });
291 }
292 }
293
294 Ok(facets)
295 }
296
297 /// Calculate creator facets (top event creators)
298 async fn calculate_creator_facets(
299 &self,
300 criteria: &EventFilterCriteria,
301 ) -> Result<Vec<FacetValue>, FilterError> {
302 let mut query = QueryBuilder::new(
303 "SELECT did, COUNT(*) as count FROM events"
304 );
305
306 // Apply filters excluding creator to show all available creators
307 let criteria_without_creator = EventFilterCriteria {
308 creator_did: None, // Exclude creator filter for facet calculation
309 ..criteria.clone()
310 };
311 self.apply_base_filters(&mut query, &criteria_without_creator);
312
313 query.push(" GROUP BY did");
314 query.push(" ORDER BY count DESC");
315 query.push(" LIMIT 20"); // Top creators only
316
317 let rows = query
318 .build()
319 .fetch_all(&self.pool)
320 .await?;
321
322 // Collect DIDs for batch handle resolution
323 let dids: Vec<String> = rows.iter()
324 .map(|row| row.try_get::<String, _>("did").unwrap_or_default())
325 .collect();
326
327 // Batch resolve handles for all DIDs
328 let handle_map = handles_by_did(&self.pool, dids)
329 .await
330 .unwrap_or_default();
331
332 let mut facets = Vec::new();
333 for row in rows {
334 let did: String = row.try_get("did")?;
335 let count: i64 = row.try_get("count")?;
336
337 // Try to get the handle, fallback to shortened DID
338 let display_name = handle_map.get(&did)
339 .map(|handle| handle.handle.clone())
340 .unwrap_or_else(|| {
341 // Create a shortened DID as fallback (show first 8 and last 8 chars)
342 if did.len() > 20 {
343 format!("{}...{}", &did[..8], &did[did.len()-8..])
344 } else {
345 did.clone()
346 }
347 });
348
349 facets.push(FacetValue {
350 value: did,
351 count,
352 i18n_key: None,
353 display_name: Some(display_name),
354 });
355 }
356
357 Ok(facets)
358 }
359
360 /// Calculate date range facets
361 async fn calculate_date_range_facets(
362 &self,
363 criteria: &EventFilterCriteria,
364 ) -> Result<Vec<FacetValue>, FilterError> {
365 let mut facets = Vec::new();
366
367 // Define date ranges to calculate
368 let date_ranges = vec![
369 ("today", "Today"),
370 ("this-week", "This Week"),
371 ("this-month", "This Month"),
372 ("next-week", "Next Week"),
373 ("next-month", "Next Month"),
374 ];
375
376 for (key, _label) in date_ranges {
377 let count = self.count_events_in_date_range(criteria, key).await?;
378 if count > 0 {
379 facets.push(FacetValue {
380 value: key.to_string(),
381 count,
382 i18n_key: Some(format!("date-range-{}", key)),
383 display_name: None, // No locale context in non-locale method
384 });
385 }
386 }
387
388 Ok(facets)
389 }
390
391 /// Calculate date range facets with locale support
392 pub async fn calculate_date_range_facets_with_locale(
393 &self,
394 criteria: &EventFilterCriteria,
395 locale: &LanguageIdentifier,
396 ) -> Result<Vec<FacetValue>, FilterError> {
397 let mut facets = Vec::new();
398
399 // Define date ranges to calculate
400 let date_ranges = vec![
401 ("today", "Today"),
402 ("this-week", "This Week"),
403 ("this-month", "This Month"),
404 ("next-week", "Next Week"),
405 ("next-month", "Next Month"),
406 ];
407
408 for (key, _label) in date_ranges {
409 let count = self.count_events_in_date_range(criteria, key).await?;
410 if count > 0 {
411 let i18n_key = format!("date-range-{}", key);
412 facets.push(FacetValue {
413 value: key.to_string(),
414 count,
415 i18n_key: Some(i18n_key.clone()),
416 display_name: Some(self.get_translated_facet_name(&i18n_key, locale)),
417 });
418 }
419 }
420
421 Ok(facets)
422 }
423
424 /// Count events in a specific date range
425 async fn count_events_in_date_range(
426 &self,
427 criteria: &EventFilterCriteria,
428 range_key: &str,
429 ) -> Result<i64, FilterError> {
430 let now = chrono::Utc::now();
431
432 let (start_date, end_date) = match range_key {
433 "today" => {
434 let start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
435 let end = now.date_naive().and_hms_opt(23, 59, 59).unwrap().and_utc();
436 (start, end)
437 }
438 "this_week" | "this-week" => {
439 let days_since_monday = now.weekday().num_days_from_monday();
440 let start = (now - chrono::Duration::days(days_since_monday as i64))
441 .date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
442 let end = start + chrono::Duration::days(6);
443 (start, end)
444 }
445 "this_month" | "this-month" => {
446 let start = now.date_naive().with_day(1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc();
447 let end = if now.month() == 12 {
448 chrono::NaiveDate::from_ymd_opt(now.year() + 1, 1, 1).unwrap()
449 } else {
450 chrono::NaiveDate::from_ymd_opt(now.year(), now.month() + 1, 1).unwrap()
451 }.and_hms_opt(0, 0, 0).unwrap().and_utc();
452 (start, end)
453 }
454 "next_week" | "next-week" => {
455 let days_until_next_monday = 7 - now.weekday().num_days_from_monday();
456 let start = (now + chrono::Duration::days(days_until_next_monday as i64))
457 .date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
458 let end = start + chrono::Duration::days(6);
459 (start, end)
460 }
461 "next_month" | "next-month" => {
462 let start = if now.month() == 12 {
463 chrono::NaiveDate::from_ymd_opt(now.year() + 1, 1, 1).unwrap()
464 } else {
465 chrono::NaiveDate::from_ymd_opt(now.year(), now.month() + 1, 1).unwrap()
466 }.and_hms_opt(0, 0, 0).unwrap().and_utc();
467 let end = if start.month() == 12 {
468 chrono::NaiveDate::from_ymd_opt(start.year() + 1, 1, 1).unwrap()
469 } else {
470 chrono::NaiveDate::from_ymd_opt(start.year(), start.month() + 1, 1).unwrap()
471 }.and_hms_opt(0, 0, 0).unwrap().and_utc();
472 (start, end)
473 }
474 _ => return Ok(0),
475 };
476
477 let mut query = QueryBuilder::new("SELECT COUNT(*) FROM events");
478
479 // Apply base filters excluding date range
480 let criteria_without_dates = EventFilterCriteria {
481 start_date: None,
482 end_date: None,
483 ..criteria.clone()
484 };
485 let has_where = self.apply_base_filters(&mut query, &criteria_without_dates);
486
487 // Add the specific date range
488 query.push(if has_where { " AND " } else { " WHERE " });
489 query.push("(record->>'startsAt')::timestamptz >= ");
490 query.push_bind(start_date);
491 query.push(" AND (record->>'startsAt')::timestamptz < ");
492 query.push_bind(end_date);
493
494 let count: (i64,) = query
495 .build_query_as()
496 .fetch_one(&self.pool)
497 .await?;
498
499 Ok(count.0)
500 }
501
502 /// Apply base filters (same as query builder but modular)
503 fn apply_base_filters<'a>(
504 &self,
505 query: &mut QueryBuilder<'a, sqlx::Postgres>,
506 criteria: &'a EventFilterCriteria,
507 ) -> bool {
508 let mut has_where = false;
509
510 // Text search
511 if let Some(ref term) = criteria.search_term {
512 if !term.trim().is_empty() {
513 query.push(if has_where { " AND " } else { " WHERE " });
514 query.push("(name ILIKE ");
515 query.push_bind(format!("%{}%", term));
516 query.push(" OR record->>'description' ILIKE ");
517 query.push_bind(format!("%{}%", term));
518 query.push(")");
519 has_where = true;
520 }
521 }
522
523 // Date filtering - using overlap logic for events within date range
524 // An event overlaps with the filter range if:
525 // event_start <= filter_end AND event_end >= filter_start
526 if criteria.start_date.is_some() || criteria.end_date.is_some() {
527 query.push(if has_where { " AND " } else { " WHERE " });
528 query.push("(");
529
530 let mut condition_added = false;
531
532 if let Some(filter_start) = criteria.start_date {
533 // Event must end on or after filter start date (for overlap)
534 // If no endsAt, use end of start day (startsAt date + 23:59:59)
535 query.push("COALESCE((record->>'endsAt')::timestamptz, \
536 DATE_TRUNC('day', (record->>'startsAt')::timestamptz) + INTERVAL '1 day' - INTERVAL '1 second') >= ");
537 query.push_bind(filter_start);
538 condition_added = true;
539 }
540
541 if let Some(filter_end) = criteria.end_date {
542 // Event must start on or before filter end date (for overlap)
543 if condition_added {
544 query.push(" AND ");
545 }
546 query.push("(record->>'startsAt')::timestamptz <= ");
547 query.push_bind(filter_end);
548 }
549
550 query.push(")");
551 has_where = true;
552 }
553
554 // Creator filtering
555 if let Some(ref creator_did) = criteria.creator_did {
556 query.push(if has_where { " AND " } else { " WHERE " });
557 query.push("did = ");
558 query.push_bind(creator_did);
559 has_where = true;
560 }
561
562 // Mode filtering
563 if !criteria.modes.is_empty() {
564 query.push(if has_where { " AND " } else { " WHERE " });
565 query.push("(");
566 for (i, mode) in criteria.modes.iter().enumerate() {
567 if i > 0 {
568 query.push(" OR ");
569 }
570 // Convert Mode enum to its string representation
571 let mode_str = match mode {
572 Mode::InPerson => "community.lexicon.calendar.event#inperson",
573 Mode::Virtual => "community.lexicon.calendar.event#virtual",
574 Mode::Hybrid => "community.lexicon.calendar.event#hybrid",
575 };
576 query.push("record->>'mode' = ");
577 query.push_bind(mode_str);
578 }
579 query.push(")");
580 has_where = true;
581 }
582
583 // Status filtering
584 if !criteria.statuses.is_empty() {
585 query.push(if has_where { " AND " } else { " WHERE " });
586 query.push("(");
587 for (i, status) in criteria.statuses.iter().enumerate() {
588 if i > 0 {
589 query.push(" OR ");
590 }
591 // Convert Status enum to its string representation
592 let status_str = match status {
593 Status::Scheduled => "community.lexicon.calendar.event#scheduled",
594 Status::Rescheduled => "community.lexicon.calendar.event#rescheduled",
595 Status::Cancelled => "community.lexicon.calendar.event#cancelled",
596 Status::Postponed => "community.lexicon.calendar.event#postponed",
597 Status::Planned => "community.lexicon.calendar.event#planned",
598 };
599 query.push("record->>'status' = ");
600 query.push_bind(status_str);
601 }
602 query.push(")");
603 has_where = true;
604 }
605
606 // Location filtering
607 if let Some(ref location) = criteria.location {
608 query.push(if has_where { " AND " } else { " WHERE " });
609 query.push("ST_DWithin(");
610 query.push("ST_MakePoint(");
611 query.push("(record->'location'->>'longitude')::float8, ");
612 query.push("(record->'location'->>'latitude')::float8");
613 query.push(")::geography, ");
614 query.push("ST_MakePoint(");
615 query.push_bind(location.longitude);
616 query.push(", ");
617 query.push_bind(location.latitude);
618 query.push(")::geography, ");
619 query.push_bind(location.radius_km * 1000.0);
620 query.push(")");
621 has_where = true;
622 }
623
624 has_where
625 }
626
627 /// Generate i18n key for mode facets
628 pub fn generate_mode_i18n_key(mode: &str) -> String {
629 match mode {
630 "community.lexicon.calendar.event#inperson" => "mode-in-person".to_string(),
631 "community.lexicon.calendar.event#virtual" => "mode-virtual".to_string(),
632 "community.lexicon.calendar.event#hybrid" => "mode-hybrid".to_string(),
633 _ => format!("mode-{}", mode.to_lowercase().replace('#', "-").replace('.', "-")),
634 }
635 }
636
637 /// Generate i18n key for status facets
638 pub fn generate_status_i18n_key(status: &str) -> String {
639 match status {
640 "community.lexicon.calendar.event#scheduled" => "status-scheduled".to_string(),
641 "community.lexicon.calendar.event#rescheduled" => "status-rescheduled".to_string(),
642 "community.lexicon.calendar.event#cancelled" => "status-cancelled".to_string(),
643 "community.lexicon.calendar.event#postponed" => "status-postponed".to_string(),
644 "community.lexicon.calendar.event#planned" => "status-planned".to_string(),
645 _ => format!("status-{}", status.to_lowercase().replace('#', "-").replace('.', "-")),
646 }
647 }
648
649
650
651
652
653
654
655 /// Get translated facet name with fallback to formatted readable name
656 pub fn get_translated_facet_name(&self, i18n_key: &str, locale: &LanguageIdentifier) -> String {
657 // Try to get the translation from the fluent loader
658 let translated = {
659 let locales = &*crate::i18n::fluent_loader::LOCALES;
660 locales.lookup(locale, i18n_key)
661 };
662
663 // If translation returns the key itself, it means no translation found
664 if translated == i18n_key {
665 // Extract readable name from key and format it nicely
666 // Examples:
667 // "date-range-today" -> "Today"
668 // "mode-inperson" -> "In Person"
669 // "status-scheduled" -> "Scheduled"
670 let readable = i18n_key
671 .split('-')
672 .skip(1) // Skip the prefix (date-range, mode, status, etc.)
673 .collect::<Vec<_>>()
674 .join(" ")
675 .split('_')
676 .map(|word| {
677 let mut chars = word.chars();
678 match chars.next() {
679 None => String::new(),
680 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
681 }
682 })
683 .collect::<Vec<_>>()
684 .join(" ");
685
686 if readable.is_empty() {
687 i18n_key.to_string()
688 } else {
689 readable
690 }
691 } else {
692 translated
693 }
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 #[test]
702 fn test_generate_mode_i18n_key() {
703 assert_eq!(
704 FacetCalculator::generate_mode_i18n_key("community.lexicon.calendar.event#inperson"),
705 "mode-in-person"
706 );
707
708 assert_eq!(
709 FacetCalculator::generate_mode_i18n_key("community.lexicon.calendar.event#virtual"),
710 "mode-virtual"
711 );
712 }
713
714 #[test]
715 fn test_generate_status_i18n_key() {
716 assert_eq!(
717 FacetCalculator::generate_status_i18n_key("community.lexicon.calendar.event#scheduled"),
718 "status-scheduled"
719 );
720
721 assert_eq!(
722 FacetCalculator::generate_status_i18n_key("community.lexicon.calendar.event#cancelled"),
723 "status-cancelled"
724 );
725 }
726}