Skip to main content

uls_db/
freshness.rs

1//! Data freshness detection and staleness utilities.
2//!
3//! Provides utilities for detecting stale data and determining what updates are needed.
4
5use chrono::{DateTime, Duration, NaiveDate, Utc};
6use serde::{Deserialize, Serialize};
7
8/// Parse FCC datetime format: "Tue Jan 13 08:00:15 EST 2026"
9fn parse_fcc_datetime(s: &str) -> Option<DateTime<Utc>> {
10    let parts: Vec<&str> = s.split_whitespace().collect();
11    if parts.len() < 6 {
12        return None;
13    }
14
15    let month = match parts[1] {
16        "Jan" => 1,
17        "Feb" => 2,
18        "Mar" => 3,
19        "Apr" => 4,
20        "May" => 5,
21        "Jun" => 6,
22        "Jul" => 7,
23        "Aug" => 8,
24        "Sep" => 9,
25        "Oct" => 10,
26        "Nov" => 11,
27        "Dec" => 12,
28        _ => return None,
29    };
30
31    let day: u32 = parts[2].parse().ok()?;
32    let time_parts: Vec<&str> = parts[3].split(':').collect();
33    if time_parts.len() != 3 {
34        return None;
35    }
36    let hour: u32 = time_parts[0].parse().ok()?;
37    let min: u32 = time_parts[1].parse().ok()?;
38    let sec: u32 = time_parts[2].parse().ok()?;
39    let year: i32 = parts[5].parse().ok()?;
40
41    let naive = chrono::NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, min, sec)?;
42
43    // EST is UTC-5, but we'll approximate as UTC for staleness purposes
44    Some(DateTime::from_naive_utc_and_offset(naive, Utc))
45}
46
47/// Default staleness threshold in days.
48pub const DEFAULT_STALE_THRESHOLD_DAYS: i64 = 3;
49
50/// Information about the freshness of data for a service.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct DataFreshness {
53    /// Radio service code (e.g., "HA", "ZA").
54    pub service: String,
55
56    /// When the data was last updated.
57    pub last_updated: Option<DateTime<Utc>>,
58
59    /// Age of the data.
60    pub age: Option<Duration>,
61
62    /// Whether the data is considered stale.
63    pub is_stale: bool,
64
65    /// Age in human-readable format.
66    pub age_display: String,
67
68    /// Date of the last applied weekly import.
69    pub last_weekly_date: Option<NaiveDate>,
70
71    /// Dates of applied patches since last weekly.
72    pub applied_patch_dates: Vec<NaiveDate>,
73
74    /// Dates of missing patches (days that should have patches but don't).
75    pub missing_patch_dates: Vec<NaiveDate>,
76}
77
78impl DataFreshness {
79    /// Create a new DataFreshness with unknown/uninitialized state.
80    pub fn unknown(service: impl Into<String>) -> Self {
81        Self {
82            service: service.into(),
83            last_updated: None,
84            age: None,
85            is_stale: true, // Unknown data is considered stale
86            age_display: "unknown".to_string(),
87            last_weekly_date: None,
88            applied_patch_dates: Vec::new(),
89            missing_patch_dates: Vec::new(),
90        }
91    }
92
93    /// Create DataFreshness from a last_updated timestamp string.
94    pub fn from_timestamp(
95        service: impl Into<String>,
96        timestamp: Option<&str>,
97        threshold_days: i64,
98    ) -> Self {
99        let service = service.into();
100
101        let last_updated = timestamp.and_then(|ts| {
102            // Try parsing various formats
103            DateTime::parse_from_rfc3339(ts)
104                .map(|dt| dt.with_timezone(&Utc))
105                .ok()
106                .or_else(|| {
107                    // Try "YYYY-MM-DD HH:MM:SS UTC" format
108                    chrono::NaiveDateTime::parse_from_str(
109                        ts.trim_end_matches(" UTC"),
110                        "%Y-%m-%d %H:%M:%S",
111                    )
112                    .ok()
113                    .map(|ndt| DateTime::from_naive_utc_and_offset(ndt, Utc))
114                })
115                .or_else(|| {
116                    // Try FCC format: "Tue Jan 13 08:00:15 EST 2026"
117                    parse_fcc_datetime(ts)
118                })
119        });
120
121        let now = Utc::now();
122        let age = last_updated.map(|lu| now.signed_duration_since(lu));
123        let threshold = Duration::days(threshold_days);
124        let is_stale = age.map(|a| a > threshold).unwrap_or(true);
125
126        let age_display = match age {
127            Some(a) => {
128                let days = a.num_days();
129                let hours = a.num_hours() % 24;
130                if days > 0 {
131                    format!("{}d {}h", days, hours)
132                } else if hours > 0 {
133                    format!("{}h", hours)
134                } else {
135                    let mins = a.num_minutes();
136                    format!("{}m", mins)
137                }
138            }
139            None => "unknown".to_string(),
140        };
141
142        Self {
143            service,
144            last_updated,
145            age,
146            is_stale,
147            age_display,
148            last_weekly_date: None,
149            applied_patch_dates: Vec::new(),
150            missing_patch_dates: Vec::new(),
151        }
152    }
153
154    /// Get the age in days, or None if unknown.
155    pub fn age_days(&self) -> Option<i64> {
156        self.age.map(|a| a.num_days())
157    }
158
159    /// Check if data needs a weekly update (new weekly available).
160    pub fn needs_weekly_update(&self) -> bool {
161        // If we don't know when last weekly was, assume we need one
162        self.last_weekly_date.is_none()
163    }
164
165    /// Check if there are missing daily patches to apply.
166    pub fn has_missing_patches(&self) -> bool {
167        !self.missing_patch_dates.is_empty()
168    }
169}
170
171/// Record of an applied daily patch.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct AppliedPatch {
174    /// Radio service code.
175    pub service: String,
176
177    /// Date of the patch (YYYY-MM-DD).
178    pub patch_date: NaiveDate,
179
180    /// Weekday abbreviation (mon, tue, etc.).
181    pub weekday: String,
182
183    /// When the patch was applied.
184    pub applied_at: DateTime<Utc>,
185
186    /// ETag of the downloaded file, if known.
187    pub etag: Option<String>,
188
189    /// Number of records in the patch.
190    pub record_count: Option<usize>,
191}
192
193/// Staleness warning configuration.
194#[derive(Debug, Clone)]
195pub struct StalenessConfig {
196    /// Threshold in days before data is considered stale.
197    pub threshold_days: i64,
198
199    /// Whether to show staleness warnings.
200    pub warn_enabled: bool,
201
202    /// Whether to auto-update when stale.
203    pub auto_update: bool,
204}
205
206impl Default for StalenessConfig {
207    fn default() -> Self {
208        Self {
209            threshold_days: DEFAULT_STALE_THRESHOLD_DAYS,
210            warn_enabled: true,
211            auto_update: false,
212        }
213    }
214}
215
216impl StalenessConfig {
217    /// Create config with warnings disabled.
218    pub fn no_warnings() -> Self {
219        Self {
220            warn_enabled: false,
221            ..Default::default()
222        }
223    }
224
225    /// Create config with auto-update enabled.
226    pub fn with_auto_update() -> Self {
227        Self {
228            auto_update: true,
229            ..Default::default()
230        }
231    }
232
233    /// Create config with custom threshold.
234    pub fn with_threshold(days: i64) -> Self {
235        Self {
236            threshold_days: days,
237            ..Default::default()
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use chrono::Datelike;
246
247    #[test]
248    fn test_freshness_from_recent_timestamp() {
249        let now = Utc::now();
250        let timestamp = now.format("%Y-%m-%d %H:%M:%S UTC").to_string();
251
252        let freshness = DataFreshness::from_timestamp("HA", Some(&timestamp), 3);
253
254        assert!(!freshness.is_stale);
255        assert!(freshness.age.is_some());
256        assert!(freshness.age_days().unwrap_or(999) < 1);
257    }
258
259    #[test]
260    fn test_freshness_from_old_timestamp() {
261        let old = Utc::now() - Duration::days(5);
262        let timestamp = old.format("%Y-%m-%d %H:%M:%S UTC").to_string();
263
264        let freshness = DataFreshness::from_timestamp("HA", Some(&timestamp), 3);
265
266        assert!(freshness.is_stale);
267        assert_eq!(freshness.age_days(), Some(5));
268    }
269
270    #[test]
271    fn test_freshness_unknown() {
272        let freshness = DataFreshness::from_timestamp("HA", None, 3);
273
274        assert!(freshness.is_stale);
275        assert!(freshness.last_updated.is_none());
276        assert_eq!(freshness.age_display, "unknown");
277    }
278
279    #[test]
280    fn test_staleness_config_defaults() {
281        let config = StalenessConfig::default();
282
283        assert_eq!(config.threshold_days, 3);
284        assert!(config.warn_enabled);
285        assert!(!config.auto_update);
286    }
287
288    #[test]
289    fn test_age_display_formatting() {
290        // Test hours display
291        let recent = Utc::now() - Duration::hours(5);
292        let timestamp = recent.format("%Y-%m-%d %H:%M:%S UTC").to_string();
293        let freshness = DataFreshness::from_timestamp("HA", Some(&timestamp), 3);
294        assert!(freshness.age_display.contains("h"));
295
296        // Test days display
297        let old = Utc::now() - Duration::days(2) - Duration::hours(3);
298        let timestamp = old.format("%Y-%m-%d %H:%M:%S UTC").to_string();
299        let freshness = DataFreshness::from_timestamp("HA", Some(&timestamp), 3);
300        assert!(freshness.age_display.contains("d"));
301    }
302
303    #[test]
304    fn test_parse_fcc_datetime() {
305        let result = parse_fcc_datetime("Tue Jan 13 08:31:48 EST 2026");
306        assert!(result.is_some());
307        let dt = result.unwrap();
308        assert_eq!(dt.year(), 2026);
309        assert_eq!(dt.month(), 1);
310        assert_eq!(dt.day(), 13);
311    }
312
313    #[test]
314    fn test_parse_fcc_datetime_invalid() {
315        assert!(parse_fcc_datetime("invalid").is_none());
316        assert!(parse_fcc_datetime("").is_none());
317        assert!(parse_fcc_datetime("2026-01-13").is_none()); // Wrong format
318    }
319
320    #[test]
321    fn test_freshness_from_fcc_format() {
322        // FCC format: "Tue Jan 13 08:31:48 EST 2026"
323        // This is older than 3 days from today (Jan 19), so should be stale
324        let freshness =
325            DataFreshness::from_timestamp("HA", Some("Tue Jan 13 08:31:48 EST 2026"), 3);
326        assert!(freshness.is_stale);
327        assert!(freshness.last_updated.is_some());
328    }
329}