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