Skip to main content

flowsurface_data/config/
timezone.rs

1use chrono::{DateTime, Datelike, Months, TimeZone};
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5const ORIGIN_YEAR: i32 = 2000;
6
7#[derive(Debug, Clone, Copy, PartialEq, Default)]
8pub enum UserTimezone {
9    #[default]
10    Utc,
11    Local,
12}
13
14/// Specifies the *purpose* of a timestamp label when requesting a formatted
15/// string from a `UserTimezone` instance.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum TimeLabelKind<'a> {
18    /// Formatting suitable for axis ticks.  Will choose the appropriate
19    /// `HH:MM`, `MM:SS`, or `D` style based on the timeframe.
20    Axis { timeframe: exchange::Timeframe },
21    /// Formatting suitable for axis ticks when the step is not an `exchange::Timeframe` variant.
22    AxisStepMs { step_ms: u64 },
23    /// Formatting for the crosshair tooltip.
24    /// Sub-10-second intervals will show `HH:MM:SS.mmm`,
25    /// while larger intervals will show `Day Mon D HH:MM`.
26    Crosshair { show_millis: bool },
27    /// Arbitrary formatting using the given `chrono` specifier string.
28    Custom(&'a str),
29}
30
31impl UserTimezone {
32    pub fn to_user_datetime(
33        &self,
34        datetime: DateTime<chrono::Utc>,
35    ) -> DateTime<chrono::FixedOffset> {
36        self.with_user_timezone(datetime, |time_with_zone| time_with_zone)
37    }
38
39    /// Formats a Unix timestamp (milliseconds) according to the kind.
40    pub fn format_with_kind(&self, timestamp_ms: i64, kind: TimeLabelKind<'_>) -> Option<String> {
41        DateTime::from_timestamp_millis(timestamp_ms).map(|datetime| {
42            self.with_user_timezone(datetime, |time_with_zone| match kind {
43                TimeLabelKind::Axis { timeframe } => {
44                    Self::format_by_timeframe(&time_with_zone, timeframe)
45                }
46                TimeLabelKind::AxisStepMs { step_ms } => {
47                    Self::format_by_step_ms(&time_with_zone, step_ms)
48                }
49                TimeLabelKind::Crosshair { show_millis } => {
50                    if show_millis {
51                        time_with_zone.format("%H:%M:%S.%3f").to_string()
52                    } else {
53                        time_with_zone.format("%a %b %-d %H:%M").to_string()
54                    }
55                }
56                TimeLabelKind::Custom(fmt) => time_with_zone.format(fmt).to_string(),
57            })
58        })
59    }
60
61    /// Converts a UTC `DateTime` into the user's configured timezone and normalizes it to
62    /// `DateTime<FixedOffset>` so downstream formatting can use one concrete type.
63    fn with_user_timezone<T>(
64        &self,
65        datetime: DateTime<chrono::Utc>,
66        formatter: impl FnOnce(DateTime<chrono::FixedOffset>) -> T,
67    ) -> T {
68        let time_with_zone = match self {
69            UserTimezone::Local => datetime.with_timezone(&chrono::Local).fixed_offset(),
70            UserTimezone::Utc => datetime.fixed_offset(),
71        };
72
73        formatter(time_with_zone)
74    }
75
76    /// Formats an already timezone-adjusted timestamp for axis labels.
77    ///
78    /// `timeframe` controls whether output is second-level (`MM:SS`) or
79    /// minute-level (`HH:MM`); calendar boundaries are rendered separately in
80    /// `data::chart::ticks::x`.
81    fn format_by_timeframe(
82        datetime: &DateTime<chrono::FixedOffset>,
83        timeframe: exchange::Timeframe,
84    ) -> String {
85        let interval = timeframe.to_milliseconds();
86
87        if interval < 10_000 {
88            datetime.format("%M:%S").to_string()
89        } else {
90            datetime.format("%H:%M").to_string()
91        }
92    }
93
94    fn format_by_step_ms(datetime: &DateTime<chrono::FixedOffset>, step_ms: u64) -> String {
95        const S: u64 = 1_000;
96        const M: u64 = 60 * S;
97        const H: u64 = 60 * M;
98        const D: u64 = 24 * H;
99
100        if step_ms < M {
101            datetime.format("%H:%M:%S").to_string()
102        } else if step_ms < D {
103            datetime.format("%H:%M").to_string()
104        } else if step_ms < 7 * D {
105            datetime.format("%b %d").to_string()
106        } else if step_ms < 365 * D {
107            datetime.format("%Y-%m").to_string()
108        } else {
109            datetime.format("%Y").to_string()
110        }
111    }
112
113    /// The calendar date of `datetime` in the user's timezone.
114    fn local_date(&self, datetime: DateTime<chrono::Utc>) -> chrono::NaiveDate {
115        match self {
116            UserTimezone::Utc => datetime.date_naive(),
117            UserTimezone::Local => datetime.with_timezone(&chrono::Local).date_naive(),
118        }
119    }
120
121    /// The UTC timestamp of local midnight (00:00 in the user's timezone) on
122    /// `date`. Uses the earliest candidate for ambiguous local times (DST
123    /// fall-back); midnight is never in a spring-forward gap in practice.
124    pub(crate) fn local_midnight_utc_ms(&self, date: chrono::NaiveDate) -> Option<u64> {
125        let midnight = date.and_hms_opt(0, 0, 0)?;
126        let utc = match self {
127            UserTimezone::Utc => midnight.and_utc(),
128            UserTimezone::Local => chrono::Local
129                .from_local_datetime(&midnight)
130                .earliest()?
131                .with_timezone(&chrono::Utc),
132        };
133        utc.timestamp_millis().try_into().ok()
134    }
135
136    /// Start of the user's local day containing `timestamp_ms`, as UTC ms.
137    pub(crate) fn start_of_local_day_utc_ms(&self, timestamp_ms: u64) -> Option<u64> {
138        let datetime = DateTime::from_timestamp_millis(timestamp_ms as i64)?;
139        self.local_midnight_utc_ms(self.local_date(datetime))
140    }
141
142    /// Start of the user's local month containing `timestamp_ms`, as UTC ms.
143    pub(crate) fn start_of_local_month_utc_ms(&self, timestamp_ms: u64) -> Option<u64> {
144        let datetime = DateTime::from_timestamp_millis(timestamp_ms as i64)?;
145        self.local_midnight_utc_ms(self.local_date(datetime).with_day(1)?)
146    }
147
148    /// Start of the user's local year containing `timestamp_ms`, as UTC ms.
149    pub(crate) fn start_of_local_year_utc_ms(&self, timestamp_ms: u64) -> Option<u64> {
150        let datetime = DateTime::from_timestamp_millis(timestamp_ms as i64)?;
151        self.local_midnight_utc_ms(self.local_date(datetime).with_month(1)?.with_day(1)?)
152    }
153
154    /// Start of the user's local day after `timestamp_ms`, as UTC ms.
155    pub(crate) fn next_local_day_utc_ms(&self, timestamp_ms: u64) -> Option<u64> {
156        let datetime = DateTime::from_timestamp_millis(timestamp_ms as i64)?;
157        self.local_midnight_utc_ms(self.local_date(datetime).succ_opt()?)
158    }
159
160    /// Start of the user's local month after `timestamp_ms`, as UTC ms.
161    pub(crate) fn next_local_month_utc_ms(&self, timestamp_ms: u64) -> Option<u64> {
162        let datetime = DateTime::from_timestamp_millis(timestamp_ms as i64)?;
163        self.local_midnight_utc_ms(
164            self.local_date(datetime)
165                .checked_add_months(Months::new(1))?,
166        )
167    }
168
169    /// Start of the user's local year after `timestamp_ms`, as UTC ms.
170    pub(crate) fn next_local_year_utc_ms(&self, timestamp_ms: u64) -> Option<u64> {
171        let datetime = DateTime::from_timestamp_millis(timestamp_ms as i64)?;
172        self.local_midnight_utc_ms(
173            self.local_date(datetime)
174                .checked_add_months(Months::new(12))?,
175        )
176    }
177
178    /// Advance `timestamp_ms` to the first local midnight whose date lies on
179    /// the `skip_days`-period grid anchored at a fixed origin date
180    /// (2000-01-01). Used to phase-align thinned boundary series (e.g. daily
181    /// marks) to *absolute* dates so the collected set stays fixed while the
182    /// view pans; without this, the thinning re-anchors to the sliding visible
183    /// edge and the whole day-label set shifts by one day whenever the edge
184    /// crosses a midnight.
185    pub(crate) fn align_to_skip_grid(&self, timestamp_ms: u64, skip_days: u64) -> Option<u64> {
186        let origin = chrono::NaiveDate::from_ymd_opt(ORIGIN_YEAR, 1, 1)?;
187        let datetime = DateTime::from_timestamp_millis(timestamp_ms as i64)?;
188        let date = self.local_date(datetime);
189        let days = date.signed_duration_since(origin).num_days();
190        let advance = advance_to_grid(skip_days, days);
191        self.local_midnight_utc_ms(date.checked_add_days(chrono::Days::new(advance))?)
192    }
193
194    /// Advance `timestamp_ms` to the first month-start that lies on the
195    /// `step_months`-period grid anchored at 2000-01, in the user's timezone.
196    pub(crate) fn align_to_month_grid(&self, timestamp_ms: u64, step_months: u64) -> Option<u64> {
197        let datetime = DateTime::from_timestamp_millis(timestamp_ms as i64)?;
198        let date = self.local_date(datetime);
199        let months = (date.year() - ORIGIN_YEAR) as i64 * 12 + i64::from(date.month0());
200        let first = date.with_day(1)?;
201        self.local_midnight_utc_ms(
202            first.checked_add_months(Months::new(advance_to_grid(step_months, months) as u32))?,
203        )
204    }
205
206    /// Advance `timestamp_ms` to the first year-start that lies on the
207    /// `step_years`-period grid anchored at 2000-01-01, in the user's timezone.
208    pub(crate) fn align_to_year_grid(&self, timestamp_ms: u64, step_years: u64) -> Option<u64> {
209        let datetime = DateTime::from_timestamp_millis(timestamp_ms as i64)?;
210        let date = self.local_date(datetime);
211        let years = (date.year() - ORIGIN_YEAR) as i64;
212        let first = date.with_month(1)?.with_day(1)?;
213        self.local_midnight_utc_ms(first.checked_add_months(Months::new(
214            (advance_to_grid(step_years, years) * 12) as u32,
215        ))?)
216    }
217}
218
219/// Forward distance from `offset` to the next multiple of `step` (0 when
220/// already aligned).
221fn advance_to_grid(step: u64, offset: i64) -> u64 {
222    let step = step.max(1) as i64;
223    ((step - offset.rem_euclid(step)) % step) as u64
224}
225
226impl fmt::Display for UserTimezone {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        match self {
229            UserTimezone::Utc => write!(f, "UTC"),
230            UserTimezone::Local => {
231                let local_offset = chrono::Local::now().offset().local_minus_utc();
232                let hours = local_offset / 3600;
233                let minutes = (local_offset % 3600) / 60;
234                write!(f, "Local (UTC {hours:+03}:{minutes:02})")
235            }
236        }
237    }
238}
239
240impl<'de> Deserialize<'de> for UserTimezone {
241    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
242    where
243        D: serde::Deserializer<'de>,
244    {
245        let timezone_str = String::deserialize(deserializer)?;
246        match timezone_str.to_lowercase().as_str() {
247            "utc" => Ok(UserTimezone::Utc),
248            "local" => Ok(UserTimezone::Local),
249            _ => Err(serde::de::Error::custom("Invalid UserTimezone")),
250        }
251    }
252}
253
254impl Serialize for UserTimezone {
255    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
256    where
257        S: serde::Serializer,
258    {
259        match self {
260            UserTimezone::Utc => serializer.serialize_str("UTC"),
261            UserTimezone::Local => serializer.serialize_str("Local"),
262        }
263    }
264}