modern_roman_clock/
lib.rs

1mod utils;
2
3use wasm_bindgen::prelude::*;
4
5use std::{convert::TryFrom, time::Duration};
6
7use chrono::{DateTime, Datelike, Days, TimeDelta, TimeZone, Timelike};
8use num_ordinal::Ordinal;
9use sunrise::sunrise_sunset;
10
11include!(concat!(env!("OUT_DIR"), "/year_owner.rs"));
12
13#[wasm_bindgen]
14pub struct RomanTime {
15    day: u32,
16    month: u32,
17    year: i32,
18    hour: i32,
19    hour_progress: f64,
20    daylight_length: chrono::Duration,
21}
22
23#[cfg(target_arch = "wasm32")]
24#[wasm_bindgen]
25impl RomanTime {
26    #[wasm_bindgen(constructor)]
27    pub fn new_js(time: js_sys::Date, tz: String, lat: f64, lng: f64) -> Self {
28        use chrono::prelude::*;
29
30        use chrono_tz::Tz;
31
32        let tz: Tz = tz.parse().unwrap();
33
34        let time = NaiveDate::from_ymd_opt(
35            time.get_full_year() as i32,
36            time.get_month() + 1,
37            time.get_date(),
38        )
39        .unwrap()
40        .and_hms_opt(time.get_hours(), time.get_minutes(), time.get_seconds())
41        .unwrap();
42
43        let time = tz.from_local_datetime(&time).unwrap();
44        RomanTime::new(time, lat, lng)
45    }
46}
47
48impl RomanTime {
49    pub fn new<Tz: TimeZone>(time: DateTime<Tz>, lat: f64, lng: f64) -> Self {
50        let (sunrise, sunset) = sunrise_sunset(lat, lng, time.year(), time.month(), time.day());
51        let timezone = time.timezone();
52        let time = time.naive_local();
53        let midnight_today = time
54            .with_hour(0)
55            .unwrap()
56            .with_minute(0)
57            .unwrap()
58            .with_second(0)
59            .unwrap();
60        let sunrise = chrono::Utc
61            .timestamp_opt(sunrise, 0)
62            .unwrap()
63            .with_timezone(&timezone)
64            .naive_local();
65        let sunset = chrono::Utc
66            .timestamp_opt(sunset, 0)
67            .unwrap()
68            .with_timezone(&timezone)
69            .naive_local();
70
71        let (hour, hour_progress) = if time < sunrise {
72            // get time until sunrise
73            let time_since_midnight = time - midnight_today;
74            let morning_night_length = sunrise - midnight_today;
75            let (hour, fract) = hour_breakdown(time_since_midnight, morning_night_length, 6);
76            (hour + 6 + 12, fract)
77        } else if time > sunset {
78            // get time since sunset
79            let midnight_tomorrow = midnight_today + Duration::from_secs(24 * 60 * 60);
80            let time_since_sunset = time - sunset;
81            let evening_night_length = midnight_tomorrow - sunset;
82            let (hour, fract) = hour_breakdown(time_since_sunset, evening_night_length, 6);
83            (hour + 12, fract)
84        } else {
85            // Daytime
86            let time_since_sunrise = time - sunrise;
87            let daylight_length = sunset - sunrise;
88            let (hour, fract) = hour_breakdown(time_since_sunrise, daylight_length, 12);
89            (hour, fract)
90        };
91
92        let roman_date = if time < sunrise {
93            time.checked_sub_days(Days::new(1)).unwrap()
94        } else {
95            time
96        };
97
98        // Here get daylight hour length and night hour length
99
100        return RomanTime {
101            day: roman_date.day(),
102            month: roman_date.month(),
103            year: roman_date.year(),
104            hour,
105            hour_progress,
106            daylight_length: chrono::Duration::seconds((sunset - sunrise).num_seconds()),
107        };
108    }
109
110    pub fn daylight_length(&self) -> chrono::Duration {
111        self.daylight_length
112    }
113
114    pub fn night_length(&self) -> chrono::Duration {
115        chrono::Duration::seconds(24 * 60 * 60) - self.daylight_length
116    }
117}
118
119const FULL_MONTHS: [u32; 7] = [1, 3, 5, 7, 8, 10, 12];
120
121#[wasm_bindgen]
122impl RomanTime {
123    pub fn year(&self) -> i32 {
124        self.year
125    }
126
127    pub fn month(&self) -> u32 {
128        self.month
129    }
130
131    pub fn day(&self) -> u32 {
132        self.day
133    }
134
135    pub fn hour(&self) -> i32 {
136        self.hour
137    }
138
139    pub fn hour_progress(&self) -> f64 {
140        self.hour_progress
141    }
142
143    #[cfg(target_arch = "wasm32")]
144    pub fn daylight_length_seconds(&self) -> i64 {
145        self.daylight_length().num_seconds()
146    }
147
148    #[cfg(target_arch = "wasm32")]
149    pub fn night_length_seconds(&self) -> i64 {
150        self.night_length().num_seconds()
151    }
152
153    pub fn year_string(&self, country_iso_3166: &str) -> String {
154        let owners: &[YearOwner] = match get_owners_for_country(country_iso_3166) {
155            Some(owners) => owners,
156            None => return self.year().to_string(),
157        };
158
159        for owner in owners.iter() {
160            for (i, years) in owner.years.iter().enumerate() {
161                if self.year >= years.0 && self.year <= years.1 {
162                    let mut year_count = 0;
163                    for j in 0..i {
164                        year_count += (owner.years[j].1 - owner.years[j].0) + 1;
165                    }
166
167                    year_count += self.year - years.0;
168
169                    year_count += 1;
170
171                    return format!(
172                        "{} year of {}",
173                        num_ordinal::Osize::from1(year_count as usize),
174                        owner.owner
175                    );
176                }
177            }
178        }
179
180        return self.year().to_string();
181    }
182
183    pub fn date_string(&self) -> String {
184        let is_full_month = FULL_MONTHS.iter().any(|&x| x == self.month);
185
186        let month = chrono::Month::try_from((self.month) as u8).unwrap();
187        let month_string = month.name().to_string();
188
189        if self.day == 1  {
190            return "Kalends of ".to_string() + &month_string;
191        }
192
193        let nones_date = if is_full_month { 7 } else { 5 };
194        if self.day <= nones_date {
195            let remaining = nones_date - self.day;
196            if remaining == 0 {
197                return format!("Nones of {}", month_string);
198            } else if remaining == 1 {
199                return format!("day before the Nones of {}", month_string);
200            }
201            return format!(
202                "{} day before the Nones of {}",
203                num_ordinal::Osize::from1(remaining as usize + 1),
204                month_string
205            );
206        }
207
208        let ides_date = if is_full_month { 15 } else { 13 };
209        if self.day <= ides_date {
210            let remaining = ides_date - self.day;
211            if remaining == 0 {
212                return format!("Ides of {}", month_string);
213            } else if remaining == 1 {
214                return format!("day before the Ides of {}", month_string);
215            }
216            return format!(
217                "{} day before the Ides of {}",
218                num_ordinal::Osize::from1(remaining as usize + 1),
219                month_string
220            );
221        }
222
223        // Confusingly, once the ides pass we talk about the next month
224        let next_month = (self.month + 1) % 12;
225        let next_month_name = chrono::Month::try_from(next_month as u8).unwrap().name();
226        let leap_year = self.year % 4 == 0 && (self.year % 100 != 0 || self.year % 400 == 0);
227        let days_in_month = match self.month {
228            2 => {
229                if leap_year {
230                    29
231                } else {
232                    28
233                }
234            }
235            4 | 6 | 9 | 11 => 30,
236            _ => 31,
237        };
238
239        let remaining = days_in_month - self.day;
240        if remaining == 0 {
241            return format!("day before the Kalends of {}", next_month_name);
242        }
243
244        format!(
245            "{} day before the Kalends of {}",
246            num_ordinal::Osize::from1(remaining as usize + 2),
247            next_month_name
248        )
249    }
250
251    pub fn hour_string(&self) -> String {
252        let hour = self.hour() + 1;
253
254        let progress_part = if self.hour_progress <= 0.25 {
255            "less than a quarter"
256        } else if self.hour_progress <= 0.5 {
257            "less than half"
258        } else if self.hour_progress <= 0.75 {
259            "less than three quarters"
260        } else {
261            "more than three quarters"
262        };
263
264        let night_time = hour >= 13;
265        let hour = if hour > 12 { hour - 12 } else { hour };
266
267        format!(
268            "{} of the {} {} hour",
269            progress_part,
270            num_ordinal::Osize::from1(hour as usize),
271            if night_time { "night" } else { "daylight" }
272        )
273    }
274
275    pub fn to_string(&self, country_iso_3166: &str) -> String {
276        format!(
277            "{} of {} {}",
278            self.hour_string(),
279            self.date_string(),
280            self.year_string(country_iso_3166),
281        )
282    }
283}
284
285fn hour_breakdown(time_since: TimeDelta, total_length: TimeDelta, hour_amount: u8) -> (i32, f64) {
286    let hour_length = total_length.num_seconds() as f64 / hour_amount as f64;
287    let hour = time_since.num_seconds() as f64 / hour_length;
288    (hour.floor() as i32, hour.fract())
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use itertools::Itertools;
295
296    const BROKEN_HILL_LAT: f64 = -31.9596256;
297    const BROKEN_HILL_LNG: f64 = 141.4575006;
298
299    #[test]
300    fn broken_hill_before_sunrise() {
301        let time = chrono_tz::Australia::Broken_Hill
302            .with_ymd_and_hms(2025, 01, 27, 6, 20, 0)
303            .unwrap();
304        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
305        assert_eq!(roman_time.day(), 26);
306        assert_eq!(roman_time.hour(), 23);
307        assert!(roman_time.hour_progress() > 0.9);
308    }
309
310    #[test]
311    fn broken_hill_just_after_sunrise() {
312        let time = chrono_tz::Australia::Broken_Hill
313            .with_ymd_and_hms(2025, 01, 27, 6, 25, 0)
314            .unwrap();
315        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
316        assert_eq!(roman_time.day(), 27);
317        assert_eq!(roman_time.hour(), 0);
318        assert!(roman_time.hour_progress() < 0.1);
319    }
320
321    #[test]
322    fn broken_hill_just_after_sunset() {
323        let time = chrono_tz::Australia::Broken_Hill
324            .with_ymd_and_hms(2025, 01, 27, 20, 10, 0)
325            .unwrap();
326        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
327        assert_eq!(roman_time.day(), 27);
328        assert_eq!(roman_time.hour(), 12);
329        assert!(roman_time.hour_progress() < 0.1);
330    }
331
332    #[test]
333    fn broken_hill_solar_noon() {
334        let time = chrono_tz::Australia::Broken_Hill
335            .with_ymd_and_hms(2025, 01, 27, 13, 16, 0)
336            .unwrap();
337        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
338        assert_eq!(roman_time.hour(), 5);
339        assert!(roman_time.hour_progress() > 0.9);
340    }
341
342    #[test]
343    fn before_sunrise_first_of_month() {
344        let time = chrono_tz::Australia::Broken_Hill
345            .with_ymd_and_hms(2025, 01, 01, 1, 0, 0)
346            .unwrap();
347        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
348        assert_eq!(roman_time.day(), 31);
349        assert_eq!(roman_time.month(), 12);
350        assert_eq!(roman_time.year(), 2024);
351    }
352
353    #[test]
354    fn year_string() {
355        let time = chrono_tz::Australia::Broken_Hill
356            .with_ymd_and_hms(1996, 01, 27, 12, 45, 0)
357            .unwrap();
358        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
359        assert_eq!(roman_time.year_string("AU"), "5th year of Paul Keating");
360
361        let time = chrono_tz::Australia::Broken_Hill
362            .with_ymd_and_hms(1941, 02, 01, 1, 0, 0)
363            .unwrap();
364        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
365        assert_eq!(
366            roman_time.year_string("AU"),
367            "second year of Robert Menzies"
368        );
369
370        let time = chrono_tz::Australia::Broken_Hill
371            .with_ymd_and_hms(1951, 02, 01, 1, 0, 0)
372            .unwrap();
373        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
374        assert_eq!(roman_time.year_string("AU"), "4th year of Robert Menzies");
375        assert_eq!(roman_time.year_string("US"), "6th year of Harry S Truman");
376    }
377
378    #[test]
379    fn date_string() {
380        let time = chrono_tz::Australia::Broken_Hill
381            .with_ymd_and_hms(1996, 02, 1, 3, 45, 0)
382            .unwrap();
383        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
384        assert_eq!(
385            roman_time.date_string(),
386            "day before the Kalends of February"
387        );
388
389        let time = chrono_tz::Australia::Broken_Hill
390            .with_ymd_and_hms(1996, 01, 27, 12, 45, 0)
391            .unwrap();
392        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
393        assert_eq!(
394            roman_time.date_string(),
395            "6th day before the Kalends of February"
396        );
397
398        let time = chrono_tz::Australia::Broken_Hill
399            .with_ymd_and_hms(1996, 03, 3, 12, 45, 0)
400            .unwrap();
401        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
402        assert_eq!(
403            roman_time.date_string(),
404            "5th day before the Nones of March"
405        );
406
407        let time = chrono_tz::Australia::Broken_Hill
408            .with_ymd_and_hms(1996, 03, 7, 7, 45, 0)
409            .unwrap();
410        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
411        assert_eq!(roman_time.date_string(), "Nones of March");
412
413        let time = chrono_tz::Australia::Broken_Hill
414            .with_ymd_and_hms(1996, 03, 15, 7, 45, 0)
415            .unwrap();
416        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
417        assert_eq!(roman_time.date_string(), "Ides of March");
418
419        let time = chrono_tz::Australia::Broken_Hill
420            .with_ymd_and_hms(1996, 03, 14, 7, 45, 0)
421            .unwrap();
422        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
423        assert_eq!(roman_time.date_string(), "day before the Ides of March");
424
425        let time = chrono_tz::Australia::Broken_Hill
426            .with_ymd_and_hms(1996, 03, 13, 7, 45, 0)
427            .unwrap();
428        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
429        assert_eq!(
430            roman_time.date_string(),
431            "third day before the Ides of March"
432        );
433    }
434
435    #[test]
436    fn hour_string() {
437        let time = chrono_tz::Australia::Broken_Hill
438            .with_ymd_and_hms(2025, 01, 28, 22, 48, 0)
439            .unwrap();
440        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
441        assert_eq!(
442            roman_time.hour_string(),
443            "less than a quarter of the 5th night hour"
444        );
445
446        let time = chrono_tz::Australia::Broken_Hill
447            .with_ymd_and_hms(1996, 01, 27, 12, 45, 0)
448            .unwrap();
449        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
450        assert_eq!(
451            roman_time.hour_string(),
452            "less than three quarters of the 6th daylight hour"
453        );
454
455        let time = chrono_tz::Australia::Broken_Hill
456            .with_ymd_and_hms(2025, 01, 28, 6, 48, 0)
457            .unwrap();
458        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
459        assert_eq!(
460            roman_time.hour_string(),
461            "less than half of the first daylight hour"
462        );
463    }
464
465    #[test]
466    fn to_string() {
467        for (hour, minute, second) in (0..24)
468            .cartesian_product(0..60)
469            .cartesian_product(0..60)
470            .map(|((h, m), s)| (h, m, s))
471        {
472            let time = chrono_tz::Australia::Broken_Hill
473                .with_ymd_and_hms(2025, 01, 28, hour, minute, second)
474                .unwrap();
475            let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
476            assert!(roman_time.to_string("AU").len() > 0);
477        }
478    }
479
480    #[test]
481    fn daylight_length() {
482        let time = chrono_tz::Australia::Broken_Hill
483            .with_ymd_and_hms(2025, 01, 28, 6, 48, 0)
484            .unwrap();
485        let roman_time = RomanTime::new(time, BROKEN_HILL_LAT, BROKEN_HILL_LNG);
486        assert_eq!(roman_time.daylight_length().num_seconds() / 12 / 60, 68);
487    }
488}