Skip to main content

tse_client/
util.rs

1//! Date helpers, Persian text cleanup, and small utilities.
2
3use time::{Date, Month, OffsetDateTime};
4
5/// Replicates the JS `cleanFa`: strips zero-width characters and normalizes
6/// the Arabic kaf/yeh to their Persian forms.
7pub fn clean_fa(s: &str) -> String {
8    s.replace('\u{200B}', "") // zero-width space
9        .replace('\u{200C}', " ") // zero-width non-joiner -> space (the JS optionally trims surrounding spaces; collapse handled by trim() at call sites)
10        .replace(['\u{200D}', '\u{FEFF}'], "") // zero-width no-break space
11        .replace('\u{0643}', "\u{06A9}") // ك -> ک
12        .replace('\u{064A}', "\u{06CC}") // ي -> ی
13}
14
15/// `Date` -> "YYYYMMDD" string (JS `dateToStr`).
16pub fn date_to_str(d: Date) -> String {
17    format!("{:04}{:02}{:02}", d.year(), d.month() as u8, d.day())
18}
19
20/// Today's local date as `Date`.
21pub fn today() -> Date {
22    OffsetDateTime::now_local()
23        .unwrap_or_else(|_| OffsetDateTime::now_utc())
24        .date()
25}
26
27fn month_from_u8(m: u8) -> Month {
28    Month::try_from(m).unwrap_or(Month::January)
29}
30
31/// "YYYYMMDD" -> `Date` (JS `strToDate`).
32pub fn str_to_date(s: &str) -> Option<Date> {
33    if s.len() < 8 {
34        return None;
35    }
36    let y: i32 = s[0..4].parse().ok()?;
37    let m: u8 = s[4..6].parse().ok()?;
38    let d: u8 = s[6..8].parse().ok()?;
39    Date::from_calendar_date(y, month_from_u8(m), d).ok()
40}
41
42/// Gregorian "YYYYMMDD" -> Jalali "YYYYMMDD" (JS `gregToShamsi`).
43/// `ptime` uses 0-based months for both input and output.
44pub fn greg_to_shamsi(s: &str) -> String {
45    if s.len() < 8 {
46        return s.to_string();
47    }
48    let y: i32 = s[0..4].parse().unwrap_or(0);
49    let m: i32 = s[4..6].parse().unwrap_or(0);
50    let d: i32 = s[6..8].parse().unwrap_or(0);
51    match ptime::from_gregorian_date(y, m - 1, d) {
52        Some(pt) => format!("{:04}{:02}{:02}", pt.tm_year, pt.tm_mon + 1, pt.tm_mday),
53        None => s.to_string(),
54    }
55}
56
57/// Jalali "YYYYMMDD" -> Gregorian "YYYYMMDD" (JS `shamsiToGreg`).
58pub fn shamsi_to_greg(s: &str) -> String {
59    if s.len() < 8 {
60        return s.to_string();
61    }
62    let y: i32 = s[0..4].parse().unwrap_or(0);
63    let m: i32 = s[4..6].parse().unwrap_or(0);
64    let d: i32 = s[6..8].parse().unwrap_or(0);
65    match ptime::from_persian_date(y, m - 1, d) {
66        Some(pt) => {
67            let g = pt.to_gregorian();
68            format!("{:04}{:02}{:02}", g.tm_year + 1900, g.tm_mon + 1, g.tm_mday)
69        }
70        None => s.to_string(),
71    }
72}
73
74/// Absolute day difference between two "YYYYMMDD" strings (JS `dayDiff`).
75pub fn day_diff(s1: &str, s2: &str) -> i64 {
76    let (d1, d2) = match (str_to_date(s1), str_to_date(s2)) {
77        (Some(a), Some(b)) => (a, b),
78        _ => return 0,
79    };
80    (d2 - d1).whole_days().abs()
81}
82
83/// Split a slice into chunks of `size` (JS `splitArr`).
84pub fn split_chunks<T: Clone>(items: &[T], size: usize) -> Vec<Vec<T>> {
85    if size == 0 {
86        return vec![];
87    }
88    items.chunks(size).map(|c| c.to_vec()).collect()
89}
90
91/// time::Weekday as JS `Date.getDay()` (Sunday = 0 .. Saturday = 6).
92fn js_weekday(d: Date) -> u8 {
93    // time::Weekday::number_days_from_sunday() returns 0..=6 with Sunday=0
94    d.weekday().number_days_from_sunday()
95}
96
97const UPDATE_INTERVAL: i64 = 1;
98const TRADING_SESSION_END_HOUR: u8 = 16;
99
100/// Replicates the JS `shouldUpdate`.
101pub fn should_update(deven: &str, last_possible_deven: &str) -> bool {
102    if deven.is_empty() || deven == "0" {
103        return true;
104    }
105    let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
106    let today_d = now.date();
107    let today_deven = date_to_str(today_d);
108    let days_passed = day_diff(last_possible_deven, deven);
109    let in_weekend = matches!(js_weekday(today_d), 4 | 5); // Thursday=4, Friday=5
110    let last_update_weekday = str_to_date(last_possible_deven)
111        .map(js_weekday)
112        .unwrap_or(0);
113
114    days_passed >= UPDATE_INTERVAL
115        && (if today_deven == last_possible_deven {
116            now.hour() > TRADING_SESSION_END_HOUR
117        } else {
118            true
119        })
120        && !(in_weekend && last_update_weekday != 3 && days_passed <= 3)
121}
122
123/// Gregorian "YYYYMMDD" for the first day of the Jalali month containing
124/// the given Gregorian "YYYYMMDD" date.
125///
126/// Returns `None` when the date can't be converted (the caller can then keep
127/// the row ungrouped). The result is the Gregorian date corresponding to the
128/// 1st of the Jalali calendar month the input date falls in, e.g. a
129/// Gregorian date in mid-Farvardin returns the Gregorian date for
130/// "<jyear>0101".
131pub fn shamsi_month_key(greg: &str) -> Option<String> {
132    let sh = greg_to_shamsi(greg);
133    if sh.len() < 6 {
134        return None;
135    }
136    // greg_to_shamsi returns the input unchanged on failure; guard against that
137    // by ensuring the conversion actually produced a Jalali string.
138    if sh == greg {
139        return None;
140    }
141
142    // Build "<jyear><jmonth>01" and convert back to Gregorian.
143    let month_start_shamsi = format!("{}01", &sh[0..6]);
144    let month_start_greg = shamsi_to_greg(&month_start_shamsi);
145
146    if month_start_greg.len() < 8 || month_start_greg == month_start_shamsi {
147        return None;
148    }
149
150    Some(month_start_greg)
151}
152
153/// Start-of-week key for grouping by Jalali weeks.
154///
155/// The Jalali (Solar Hijri) week begins on Saturday (شنبه) and ends on Friday
156/// (جمعه). This returns the Gregorian "YYYYMMDD" date of the Saturday that
157/// starts the week containing `greg`, which is used as a stable grouping key.
158/// Returns `None` when the date can't be parsed.
159pub fn shamsi_week_key(greg: &str) -> Option<String> {
160    let date = str_to_date(greg)?;
161    // `number_days_from_sunday()` is Sunday=0 .. Saturday=6. The Jalali week
162    // starts on Saturday, so shift the origin: days since Saturday is
163    // (sunday_index + 1) % 7 (Sat->0, Sun->1, ... Fri->6).
164    let days_since_saturday = ((date.weekday().number_days_from_sunday() + 1) % 7) as i64;
165    let week_start = date - time::Duration::days(days_since_saturday);
166    Some(date_to_str(week_start))
167}