Skip to main content

lora_store/
temporal.rs

1use std::cmp::Ordering;
2use std::fmt;
3
4// ===== Calendar helpers =====
5
6pub fn is_leap_year(year: i32) -> bool {
7    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
8}
9
10pub fn days_in_month(year: i32, month: u32) -> u32 {
11    match month {
12        1 => 31,
13        2 => {
14            if is_leap_year(year) {
15                29
16            } else {
17                28
18            }
19        }
20        3 => 31,
21        4 => 30,
22        5 => 31,
23        6 => 30,
24        7 => 31,
25        8 => 31,
26        9 => 30,
27        10 => 31,
28        11 => 30,
29        12 => 31,
30        _ => 0,
31    }
32}
33
34/// Days since 1970-01-01 (Unix epoch) from a civil date.
35/// Uses Howard Hinnant's algorithms.
36fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
37    let y = y as i64 - if m <= 2 { 1 } else { 0 };
38    let m = m as i64;
39    let d = d as i64;
40    let era = if y >= 0 { y } else { y - 399 } / 400;
41    let yoe = (y - era * 400) as u64;
42    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
43    let doe = yoe as i64 * 365 + yoe as i64 / 4 - yoe as i64 / 100 + doy;
44    era * 146097 + doe - 719468
45}
46
47/// Civil date from days since 1970-01-01.
48fn civil_from_days(z: i64) -> (i32, u32, u32) {
49    let z = z + 719468;
50    let era = if z >= 0 { z } else { z - 146096 } / 146097;
51    let doe = (z - era * 146097) as u64;
52    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
53    let y = yoe as i64 + era * 400;
54    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
55    let mp = (5 * doy + 2) / 153;
56    let d = doy - (153 * mp + 2) / 5 + 1;
57    let m = if mp < 10 { mp + 3 } else { mp - 9 };
58    let y = if m <= 2 { y + 1 } else { y };
59    (y as i32, m as u32, d as u32)
60}
61
62/// Seconds and nanoseconds since the Unix epoch.
63///
64/// On native targets this reads `SystemTime::now()`; on
65/// `wasm32-unknown-unknown` (where `SystemTime::now()` panics) it falls
66/// back to `js_sys::Date::now()`, which returns UTC milliseconds. The
67/// browser clock is millisecond-granular, so the returned nanoseconds are
68/// only filled to millisecond precision on wasm32.
69#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
70fn unix_now() -> (u64, u32) {
71    use std::time::{SystemTime, UNIX_EPOCH};
72    let dur = SystemTime::now()
73        .duration_since(UNIX_EPOCH)
74        .unwrap_or_default();
75    (dur.as_secs(), dur.subsec_nanos())
76}
77
78#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
79fn unix_now() -> (u64, u32) {
80    let ms = js_sys::Date::now();
81    if !ms.is_finite() || ms < 0.0 {
82        return (0, 0);
83    }
84    let secs = (ms / 1_000.0).floor();
85    let nanos = ((ms - secs * 1_000.0) * 1_000_000.0).round();
86    (secs as u64, nanos as u32)
87}
88
89// ===== LoraDate =====
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct LoraDate {
93    pub year: i32,
94    pub month: u32,
95    pub day: u32,
96}
97
98impl LoraDate {
99    pub fn new(year: i32, month: u32, day: u32) -> Result<Self, String> {
100        if !(1..=12).contains(&month) {
101            return Err(format!("Invalid month: {month}"));
102        }
103        let max = days_in_month(year, month);
104        if day < 1 || day > max {
105            return Err(format!("Invalid day {day} for {year}-{month:02}"));
106        }
107        Ok(Self { year, month, day })
108    }
109
110    pub fn parse(s: &str) -> Result<Self, String> {
111        let parts: Vec<&str> = s.split('-').collect();
112        if parts.len() != 3 {
113            return Err(format!("Invalid date format: {s}"));
114        }
115        let year = parts[0]
116            .parse::<i32>()
117            .map_err(|_| format!("Invalid date: {s}"))?;
118        let month = parts[1]
119            .parse::<u32>()
120            .map_err(|_| format!("Invalid date: {s}"))?;
121        let day = parts[2]
122            .parse::<u32>()
123            .map_err(|_| format!("Invalid date: {s}"))?;
124        Self::new(year, month, day)
125    }
126
127    pub fn today() -> Self {
128        let (secs, _) = unix_now();
129        let days = (secs / 86400) as i64;
130        Self::from_epoch_days(days)
131    }
132
133    pub fn to_epoch_days(&self) -> i64 {
134        days_from_civil(self.year, self.month, self.day)
135    }
136
137    pub fn from_epoch_days(days: i64) -> Self {
138        let (y, m, d) = civil_from_days(days);
139        Self {
140            year: y,
141            month: m,
142            day: d,
143        }
144    }
145
146    pub fn day_of_week(&self) -> u32 {
147        let z = self.to_epoch_days();
148        (((z % 7) + 7 + 3) % 7 + 1) as u32
149    }
150
151    pub fn day_of_year(&self) -> u32 {
152        let mut doy = self.day;
153        for m in 1..self.month {
154            doy += days_in_month(self.year, m);
155        }
156        doy
157    }
158
159    pub fn add_duration(&self, dur: &LoraDuration) -> Self {
160        // Add months first
161        let total_months = self.year as i64 * 12 + (self.month as i64 - 1) + dur.months;
162        let new_year = total_months.div_euclid(12) as i32;
163        let new_month = (total_months.rem_euclid(12) + 1) as u32;
164        let max_day = days_in_month(new_year, new_month);
165        let new_day = self.day.min(max_day);
166        // Then add days
167        let epoch = days_from_civil(new_year, new_month, new_day) + dur.days;
168        Self::from_epoch_days(epoch)
169    }
170
171    pub fn sub_duration(&self, dur: &LoraDuration) -> Self {
172        self.add_duration(&dur.negate())
173    }
174
175    pub fn truncate_to_month(&self) -> Self {
176        Self {
177            year: self.year,
178            month: self.month,
179            day: 1,
180        }
181    }
182}
183
184impl PartialOrd for LoraDate {
185    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
186        Some(self.cmp(other))
187    }
188}
189
190impl Ord for LoraDate {
191    fn cmp(&self, other: &Self) -> Ordering {
192        self.year
193            .cmp(&other.year)
194            .then(self.month.cmp(&other.month))
195            .then(self.day.cmp(&other.day))
196    }
197}
198
199impl fmt::Display for LoraDate {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
202    }
203}
204
205// ===== LoraTime =====
206
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct LoraTime {
209    pub hour: u32,
210    pub minute: u32,
211    pub second: u32,
212    pub nanosecond: u32,
213    pub offset_seconds: i32,
214}
215
216impl LoraTime {
217    pub fn new(
218        hour: u32,
219        minute: u32,
220        second: u32,
221        nanosecond: u32,
222        offset_seconds: i32,
223    ) -> Result<Self, String> {
224        if hour > 23 {
225            return Err(format!("Invalid hour: {hour}"));
226        }
227        if minute > 59 {
228            return Err(format!("Invalid minute: {minute}"));
229        }
230        if second > 59 {
231            return Err(format!("Invalid second: {second}"));
232        }
233        Ok(Self {
234            hour,
235            minute,
236            second,
237            nanosecond,
238            offset_seconds,
239        })
240    }
241
242    pub fn parse(s: &str) -> Result<Self, String> {
243        let (h, m, sec, ns, offset) = parse_time_string(s)?;
244        let offset = offset.unwrap_or(0);
245        Self::new(h, m, sec, ns, offset)
246    }
247
248    pub fn now() -> Self {
249        let (secs, nanos) = unix_now();
250        let day_secs = secs % 86400;
251        Self {
252            hour: (day_secs / 3600) as u32,
253            minute: ((day_secs % 3600) / 60) as u32,
254            second: (day_secs % 60) as u32,
255            nanosecond: nanos,
256            offset_seconds: 0,
257        }
258    }
259}
260
261impl fmt::Display for LoraTime {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)?;
264        format_subsecond(f, self.nanosecond)?;
265        format_offset(f, self.offset_seconds)
266    }
267}
268
269// ===== LoraLocalTime =====
270
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct LoraLocalTime {
273    pub hour: u32,
274    pub minute: u32,
275    pub second: u32,
276    pub nanosecond: u32,
277}
278
279impl LoraLocalTime {
280    pub fn new(hour: u32, minute: u32, second: u32, nanosecond: u32) -> Result<Self, String> {
281        if hour > 23 {
282            return Err(format!("Invalid hour: {hour}"));
283        }
284        if minute > 59 {
285            return Err(format!("Invalid minute: {minute}"));
286        }
287        if second > 59 {
288            return Err(format!("Invalid second: {second}"));
289        }
290        Ok(Self {
291            hour,
292            minute,
293            second,
294            nanosecond,
295        })
296    }
297
298    pub fn parse(s: &str) -> Result<Self, String> {
299        let (h, m, sec, ns, _) = parse_time_string(s)?;
300        Self::new(h, m, sec, ns)
301    }
302
303    pub fn now() -> Self {
304        let (secs, nanos) = unix_now();
305        let day_secs = secs % 86400;
306        Self {
307            hour: (day_secs / 3600) as u32,
308            minute: ((day_secs % 3600) / 60) as u32,
309            second: (day_secs % 60) as u32,
310            nanosecond: nanos,
311        }
312    }
313}
314
315impl fmt::Display for LoraLocalTime {
316    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
317        write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)?;
318        format_subsecond(f, self.nanosecond)
319    }
320}
321
322// ===== LoraDateTime =====
323
324#[derive(Debug, Clone, PartialEq, Eq)]
325pub struct LoraDateTime {
326    pub year: i32,
327    pub month: u32,
328    pub day: u32,
329    pub hour: u32,
330    pub minute: u32,
331    pub second: u32,
332    pub nanosecond: u32,
333    pub offset_seconds: i32,
334}
335
336impl LoraDateTime {
337    #[allow(clippy::too_many_arguments)] // Structural datetime constructor — every field is required.
338    pub fn new(
339        year: i32,
340        month: u32,
341        day: u32,
342        hour: u32,
343        minute: u32,
344        second: u32,
345        nanosecond: u32,
346        offset_seconds: i32,
347    ) -> Result<Self, String> {
348        LoraDate::new(year, month, day)?;
349        if hour > 23 {
350            return Err(format!("Invalid hour: {hour}"));
351        }
352        if minute > 59 {
353            return Err(format!("Invalid minute: {minute}"));
354        }
355        if second > 59 {
356            return Err(format!("Invalid second: {second}"));
357        }
358        Ok(Self {
359            year,
360            month,
361            day,
362            hour,
363            minute,
364            second,
365            nanosecond,
366            offset_seconds,
367        })
368    }
369
370    pub fn parse(s: &str) -> Result<Self, String> {
371        let t_pos = s
372            .find('T')
373            .ok_or_else(|| format!("Invalid datetime: {s}"))?;
374        let date_part = &s[..t_pos];
375        let time_part = &s[t_pos + 1..];
376
377        let date = LoraDate::parse(date_part)?;
378        let (h, m, sec, ns, offset) = parse_time_string(time_part)?;
379        let offset = offset.unwrap_or(0);
380
381        Self::new(date.year, date.month, date.day, h, m, sec, ns, offset)
382    }
383
384    pub fn now() -> Self {
385        let (secs, nanos) = unix_now();
386        let days = (secs / 86400) as i64;
387        let day_secs = secs % 86400;
388        let (y, mo, d) = civil_from_days(days);
389        Self {
390            year: y,
391            month: mo,
392            day: d,
393            hour: (day_secs / 3600) as u32,
394            minute: ((day_secs % 3600) / 60) as u32,
395            second: (day_secs % 60) as u32,
396            nanosecond: nanos,
397            offset_seconds: 0,
398        }
399    }
400
401    /// Milliseconds since Unix epoch, normalized to UTC.
402    pub fn to_epoch_millis(&self) -> i64 {
403        let days = days_from_civil(self.year, self.month, self.day);
404        let day_secs = self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64;
405        let utc_secs = days * 86400 + day_secs - self.offset_seconds as i64;
406        utc_secs * 1000 + self.nanosecond as i64 / 1_000_000
407    }
408
409    pub fn add_duration(&self, dur: &LoraDuration) -> Self {
410        // Add months
411        let total_months = self.year as i64 * 12 + (self.month as i64 - 1) + dur.months;
412        let new_year = total_months.div_euclid(12) as i32;
413        let new_month = (total_months.rem_euclid(12) + 1) as u32;
414        let max_day = days_in_month(new_year, new_month);
415        let new_day = self.day.min(max_day);
416
417        // Add days + seconds
418        let base_days = days_from_civil(new_year, new_month, new_day) + dur.days;
419        let base_secs =
420            self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64 + dur.seconds;
421
422        let total_secs = base_days * 86400 + base_secs;
423        let final_days = total_secs.div_euclid(86400);
424        let rem = total_secs.rem_euclid(86400);
425        let (y, m, d) = civil_from_days(final_days);
426
427        Self {
428            year: y,
429            month: m,
430            day: d,
431            hour: (rem / 3600) as u32,
432            minute: ((rem % 3600) / 60) as u32,
433            second: (rem % 60) as u32,
434            nanosecond: self.nanosecond,
435            offset_seconds: self.offset_seconds,
436        }
437    }
438
439    pub fn truncate_to_day(&self) -> Self {
440        Self {
441            year: self.year,
442            month: self.month,
443            day: self.day,
444            hour: 0,
445            minute: 0,
446            second: 0,
447            nanosecond: 0,
448            offset_seconds: self.offset_seconds,
449        }
450    }
451
452    pub fn truncate_to_hour(&self) -> Self {
453        Self {
454            year: self.year,
455            month: self.month,
456            day: self.day,
457            hour: self.hour,
458            minute: 0,
459            second: 0,
460            nanosecond: 0,
461            offset_seconds: self.offset_seconds,
462        }
463    }
464
465    pub fn date(&self) -> LoraDate {
466        LoraDate {
467            year: self.year,
468            month: self.month,
469            day: self.day,
470        }
471    }
472}
473
474impl PartialOrd for LoraDateTime {
475    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
476        Some(self.cmp(other))
477    }
478}
479
480impl Ord for LoraDateTime {
481    fn cmp(&self, other: &Self) -> Ordering {
482        self.to_epoch_millis().cmp(&other.to_epoch_millis())
483    }
484}
485
486impl fmt::Display for LoraDateTime {
487    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
488        write!(
489            f,
490            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
491            self.year, self.month, self.day, self.hour, self.minute, self.second
492        )?;
493        format_subsecond(f, self.nanosecond)?;
494        format_offset(f, self.offset_seconds)
495    }
496}
497
498// ===== LoraLocalDateTime =====
499
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub struct LoraLocalDateTime {
502    pub year: i32,
503    pub month: u32,
504    pub day: u32,
505    pub hour: u32,
506    pub minute: u32,
507    pub second: u32,
508    pub nanosecond: u32,
509}
510
511impl LoraLocalDateTime {
512    pub fn parse(s: &str) -> Result<Self, String> {
513        let t_pos = s
514            .find('T')
515            .ok_or_else(|| format!("Invalid localdatetime: {s}"))?;
516        let date = LoraDate::parse(&s[..t_pos])?;
517        let (h, m, sec, ns, _) = parse_time_string(&s[t_pos + 1..])?;
518        if h > 23 {
519            return Err(format!("Invalid hour: {h}"));
520        }
521        if m > 59 {
522            return Err(format!("Invalid minute: {m}"));
523        }
524        if sec > 59 {
525            return Err(format!("Invalid second: {sec}"));
526        }
527        Ok(Self {
528            year: date.year,
529            month: date.month,
530            day: date.day,
531            hour: h,
532            minute: m,
533            second: sec,
534            nanosecond: ns,
535        })
536    }
537
538    pub fn now() -> Self {
539        let (secs, nanos) = unix_now();
540        let days = (secs / 86400) as i64;
541        let day_secs = secs % 86400;
542        let (y, mo, d) = civil_from_days(days);
543        Self {
544            year: y,
545            month: mo,
546            day: d,
547            hour: (day_secs / 3600) as u32,
548            minute: ((day_secs % 3600) / 60) as u32,
549            second: (day_secs % 60) as u32,
550            nanosecond: nanos,
551        }
552    }
553}
554
555impl fmt::Display for LoraLocalDateTime {
556    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
557        write!(
558            f,
559            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
560            self.year, self.month, self.day, self.hour, self.minute, self.second
561        )?;
562        format_subsecond(f, self.nanosecond)
563    }
564}
565
566// ===== LoraDuration =====
567
568#[derive(Debug, Clone, PartialEq, Eq)]
569pub struct LoraDuration {
570    pub months: i64,
571    pub days: i64,
572    pub seconds: i64,
573    pub nanoseconds: i64,
574}
575
576impl LoraDuration {
577    pub fn zero() -> Self {
578        Self {
579            months: 0,
580            days: 0,
581            seconds: 0,
582            nanoseconds: 0,
583        }
584    }
585
586    pub fn parse(s: &str) -> Result<Self, String> {
587        let s = s.trim();
588        if !s.starts_with('P') {
589            return Err(format!("Invalid duration format: {s}"));
590        }
591        let rest = &s[1..];
592        if rest.is_empty() {
593            return Err(format!("Invalid duration: {s}"));
594        }
595
596        let mut months: i64 = 0;
597        let mut days: i64 = 0;
598        let mut seconds: i64 = 0;
599        let mut nanoseconds: i64 = 0;
600        let mut in_time = false;
601        let mut num_buf = String::new();
602
603        for c in rest.chars() {
604            match c {
605                'T' => {
606                    in_time = true;
607                }
608                '0'..='9' | '.' => {
609                    num_buf.push(c);
610                }
611                'Y' if !in_time => {
612                    let n: i64 = num_buf
613                        .parse()
614                        .map_err(|_| format!("Invalid duration: {s}"))?;
615                    months += n * 12;
616                    num_buf.clear();
617                }
618                'M' if !in_time => {
619                    let n: i64 = num_buf
620                        .parse()
621                        .map_err(|_| format!("Invalid duration: {s}"))?;
622                    months += n;
623                    num_buf.clear();
624                }
625                'W' if !in_time => {
626                    let n: i64 = num_buf
627                        .parse()
628                        .map_err(|_| format!("Invalid duration: {s}"))?;
629                    days += n * 7;
630                    num_buf.clear();
631                }
632                'D' => {
633                    let n: i64 = num_buf
634                        .parse()
635                        .map_err(|_| format!("Invalid duration: {s}"))?;
636                    days += n;
637                    num_buf.clear();
638                }
639                'H' if in_time => {
640                    let n: i64 = num_buf
641                        .parse()
642                        .map_err(|_| format!("Invalid duration: {s}"))?;
643                    seconds += n * 3600;
644                    num_buf.clear();
645                }
646                'M' if in_time => {
647                    let n: i64 = num_buf
648                        .parse()
649                        .map_err(|_| format!("Invalid duration: {s}"))?;
650                    seconds += n * 60;
651                    num_buf.clear();
652                }
653                'S' if in_time => {
654                    if num_buf.contains('.') {
655                        let n: f64 = num_buf
656                            .parse()
657                            .map_err(|_| format!("Invalid duration: {s}"))?;
658                        seconds += n.floor() as i64;
659                        let frac = n - n.floor();
660                        if frac > 0.0 {
661                            nanoseconds += (frac * 1_000_000_000.0) as i64;
662                        }
663                    } else {
664                        let n: i64 = num_buf
665                            .parse()
666                            .map_err(|_| format!("Invalid duration: {s}"))?;
667                        seconds += n;
668                    }
669                    num_buf.clear();
670                }
671                _ => return Err(format!("Invalid duration format: {s}")),
672            }
673        }
674
675        if !num_buf.is_empty() {
676            return Err(format!("Trailing number in duration: {s}"));
677        }
678
679        Ok(Self {
680            months,
681            days,
682            seconds,
683            nanoseconds,
684        })
685    }
686
687    pub fn negate(&self) -> Self {
688        Self {
689            months: -self.months,
690            days: -self.days,
691            seconds: -self.seconds,
692            nanoseconds: -self.nanoseconds,
693        }
694    }
695
696    pub fn add(&self, other: &Self) -> Self {
697        Self {
698            months: self.months + other.months,
699            days: self.days + other.days,
700            seconds: self.seconds + other.seconds,
701            nanoseconds: self.nanoseconds + other.nanoseconds,
702        }
703    }
704
705    pub fn mul_int(&self, n: i64) -> Self {
706        Self {
707            months: self.months * n,
708            days: self.days * n,
709            seconds: self.seconds * n,
710            nanoseconds: self.nanoseconds * n,
711        }
712    }
713
714    pub fn div_int(&self, n: i64) -> Self {
715        if n == 0 {
716            return Self::zero();
717        }
718        Self {
719            months: self.months / n,
720            days: self.days / n,
721            seconds: self.seconds / n,
722            nanoseconds: self.nanoseconds / n,
723        }
724    }
725
726    /// Duration from date1 to date2 expressed as months + days.
727    pub fn between_dates(from: &LoraDate, to: &LoraDate) -> Self {
728        let sign: i64 = if from <= to { 1 } else { -1 };
729        let (earlier, later) = if from <= to { (from, to) } else { (to, from) };
730
731        // Count full months
732        let mut months = (later.year as i64 - earlier.year as i64) * 12
733            + (later.month as i64 - earlier.month as i64);
734
735        // Apply months to earlier and check if we overshot
736        let intermediate = earlier.add_duration(&LoraDuration {
737            months,
738            days: 0,
739            seconds: 0,
740            nanoseconds: 0,
741        });
742        if intermediate.to_epoch_days() > later.to_epoch_days() {
743            months -= 1;
744        }
745        let intermediate = earlier.add_duration(&LoraDuration {
746            months,
747            days: 0,
748            seconds: 0,
749            nanoseconds: 0,
750        });
751        let remaining_days = later.to_epoch_days() - intermediate.to_epoch_days();
752
753        Self {
754            months: months * sign,
755            days: remaining_days * sign,
756            seconds: 0,
757            nanoseconds: 0,
758        }
759    }
760
761    /// Duration from date1 to date2 expressed purely in days.
762    pub fn in_days(from: &LoraDate, to: &LoraDate) -> Self {
763        let days = to.to_epoch_days() - from.to_epoch_days();
764        Self {
765            months: 0,
766            days,
767            seconds: 0,
768            nanoseconds: 0,
769        }
770    }
771
772    /// Duration between two datetimes, expressed in days + seconds.
773    pub fn between_datetimes(from: &LoraDateTime, to: &LoraDateTime) -> Self {
774        let ms_diff = to.to_epoch_millis() - from.to_epoch_millis();
775        let total_secs = ms_diff / 1000;
776        let remaining_ms = ms_diff % 1000;
777        Self {
778            months: 0,
779            days: total_secs / 86400,
780            seconds: total_secs % 86400,
781            nanoseconds: remaining_ms * 1_000_000,
782        }
783    }
784
785    /// Approximate total seconds for ordering purposes.
786    pub fn total_seconds_approx(&self) -> f64 {
787        // 1 month ≈ 30.4375 days
788        self.months as f64 * 2_629_800.0
789            + self.days as f64 * 86400.0
790            + self.seconds as f64
791            + self.nanoseconds as f64 / 1_000_000_000.0
792    }
793
794    pub fn years_component(&self) -> i64 {
795        self.months / 12
796    }
797    pub fn months_component(&self) -> i64 {
798        self.months % 12
799    }
800    pub fn days_component(&self) -> i64 {
801        self.days
802    }
803    pub fn hours_component(&self) -> i64 {
804        self.seconds / 3600
805    }
806    pub fn minutes_component(&self) -> i64 {
807        (self.seconds % 3600) / 60
808    }
809    pub fn seconds_component(&self) -> i64 {
810        self.seconds % 60
811    }
812}
813
814impl PartialOrd for LoraDuration {
815    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
816        Some(self.cmp(other))
817    }
818}
819
820impl Ord for LoraDuration {
821    fn cmp(&self, other: &Self) -> Ordering {
822        // total_seconds_approx returns f64; total_cmp gives a total order
823        // (NaN comparisons become deterministic) so Ord can be authoritative.
824        self.total_seconds_approx()
825            .total_cmp(&other.total_seconds_approx())
826    }
827}
828
829impl fmt::Display for LoraDuration {
830    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
831        write!(f, "P")?;
832        let years = self.months / 12;
833        let months = self.months % 12;
834        if years != 0 {
835            write!(f, "{}Y", years)?;
836        }
837        if months != 0 {
838            write!(f, "{}M", months)?;
839        }
840        if self.days != 0 {
841            write!(f, "{}D", self.days)?;
842        }
843
844        let hours = self.seconds / 3600;
845        let minutes = (self.seconds % 3600) / 60;
846        let secs = self.seconds % 60;
847
848        if hours != 0 || minutes != 0 || secs != 0 || self.nanoseconds != 0 {
849            write!(f, "T")?;
850            if hours != 0 {
851                write!(f, "{}H", hours)?;
852            }
853            if minutes != 0 {
854                write!(f, "{}M", minutes)?;
855            }
856            if secs != 0 {
857                write!(f, "{}S", secs)?;
858            } else if self.nanoseconds != 0 {
859                write!(f, "0.{:09}S", self.nanoseconds)?;
860            }
861        }
862
863        // Zero duration
864        if self.months == 0 && self.days == 0 && self.seconds == 0 && self.nanoseconds == 0 {
865            write!(f, "0D")?;
866        }
867
868        Ok(())
869    }
870}
871
872// ===== Parsing helpers =====
873
874/// Parse a time string returning (hour, minute, second, nanosecond, optional offset_seconds).
875fn parse_time_string(s: &str) -> Result<(u32, u32, u32, u32, Option<i32>), String> {
876    // Find offset suffix: Z, +HH:MM, -HH:MM
877    let (time_str, offset) = if let Some(stripped) = s.strip_suffix('Z') {
878        (stripped, Some(0i32))
879    } else if let Some(pos) = s.rfind('+') {
880        if pos >= 2 {
881            let off = parse_offset(&s[pos..])?;
882            (&s[..pos], Some(off))
883        } else {
884            (s, None)
885        }
886    } else {
887        // Look for a '-' that is part of an offset (after HH:MM:SS portion)
888        // Time format is at least HH:MM = 5 chars
889        let search_start = 5.min(s.len());
890        if let Some(rel_pos) = s[search_start..].rfind('-') {
891            let pos = search_start + rel_pos;
892            let off = parse_offset(&s[pos..])?;
893            (&s[..pos], Some(off))
894        } else {
895            (s, None)
896        }
897    };
898
899    let parts: Vec<&str> = time_str.split(':').collect();
900    if parts.len() < 2 || parts.len() > 3 {
901        return Err(format!("Invalid time: {s}"));
902    }
903
904    let hour = parts[0]
905        .parse::<u32>()
906        .map_err(|_| format!("Invalid time: {s}"))?;
907    let minute = parts[1]
908        .parse::<u32>()
909        .map_err(|_| format!("Invalid time: {s}"))?;
910
911    let (second, nanosecond) = if parts.len() == 3 {
912        parse_seconds_and_fraction(parts[2])?
913    } else {
914        (0, 0)
915    };
916
917    Ok((hour, minute, second, nanosecond, offset))
918}
919
920fn parse_seconds_and_fraction(s: &str) -> Result<(u32, u32), String> {
921    if let Some(dot_pos) = s.find('.') {
922        let sec = s[..dot_pos]
923            .parse::<u32>()
924            .map_err(|_| format!("Invalid seconds: {s}"))?;
925        let frac = &s[dot_pos + 1..];
926        // Pad/truncate to 9 digits for nanoseconds
927        let padded = format!("{:0<9}", frac);
928        let ns = padded[..9].parse::<u32>().unwrap_or(0);
929        Ok((sec, ns))
930    } else {
931        let sec = s
932            .parse::<u32>()
933            .map_err(|_| format!("Invalid seconds: {s}"))?;
934        Ok((sec, 0))
935    }
936}
937
938fn parse_offset(s: &str) -> Result<i32, String> {
939    let sign = if s.starts_with('+') {
940        1
941    } else if s.starts_with('-') {
942        -1
943    } else {
944        return Err(format!("Invalid offset: {s}"));
945    };
946    let rest = &s[1..];
947    let parts: Vec<&str> = rest.split(':').collect();
948    if parts.len() != 2 {
949        return Err(format!("Invalid offset: {s}"));
950    }
951    let h = parts[0]
952        .parse::<i32>()
953        .map_err(|_| format!("Invalid offset: {s}"))?;
954    let m = parts[1]
955        .parse::<i32>()
956        .map_err(|_| format!("Invalid offset: {s}"))?;
957    Ok(sign * (h * 3600 + m * 60))
958}
959
960fn format_offset(f: &mut fmt::Formatter<'_>, offset_seconds: i32) -> fmt::Result {
961    if offset_seconds == 0 {
962        write!(f, "Z")
963    } else {
964        let sign = if offset_seconds >= 0 { '+' } else { '-' };
965        let abs = offset_seconds.unsigned_abs();
966        let h = abs / 3600;
967        let m = (abs % 3600) / 60;
968        write!(f, "{}{:02}:{:02}", sign, h, m)
969    }
970}
971
972fn format_subsecond(f: &mut fmt::Formatter<'_>, nanosecond: u32) -> fmt::Result {
973    if nanosecond > 0 {
974        let ms = nanosecond / 1_000_000;
975        if ms > 0 && nanosecond.is_multiple_of(1_000_000) {
976            write!(f, ".{:03}", ms)
977        } else {
978            write!(f, ".{:09}", nanosecond)
979        }
980    } else {
981        Ok(())
982    }
983}