Skip to main content

flowsurface_data/config/
timezone.rs

1use chrono::DateTime;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5#[derive(Debug, Clone, Copy, PartialEq, Default)]
6pub enum UserTimezone {
7    #[default]
8    Utc,
9    Local,
10}
11
12/// Specifies the *purpose* of a timestamp label when requesting a formatted
13/// string from a `UserTimezone` instance.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum TimeLabelKind<'a> {
16    /// Formatting suitable for axis ticks.  Will choose the appropriate
17    /// `HH:MM`, `MM:SS`, or `D` style based on the timeframe.
18    Axis { timeframe: exchange::Timeframe },
19    /// Formatting for the crosshair tooltip.
20    /// Sub-10-second intervals will show `HH:MM:SS.mmm`,
21    /// while larger intervals will show `Day Mon D HH:MM`.
22    Crosshair { show_millis: bool },
23    /// Arbitrary formatting using the given `chrono` specifier string.
24    Custom(&'a str),
25}
26
27impl UserTimezone {
28    pub fn to_user_datetime(
29        &self,
30        datetime: DateTime<chrono::Utc>,
31    ) -> DateTime<chrono::FixedOffset> {
32        self.with_user_timezone(datetime, |time_with_zone| time_with_zone)
33    }
34
35    /// Formats a Unix timestamp (milliseconds) according to the kind.
36    pub fn format_with_kind(&self, timestamp_ms: i64, kind: TimeLabelKind<'_>) -> Option<String> {
37        DateTime::from_timestamp_millis(timestamp_ms).map(|datetime| {
38            self.with_user_timezone(datetime, |time_with_zone| match kind {
39                TimeLabelKind::Axis { timeframe } => {
40                    Self::format_by_timeframe(&time_with_zone, timeframe)
41                }
42                TimeLabelKind::Crosshair { show_millis } => {
43                    if show_millis {
44                        time_with_zone.format("%H:%M:%S.%3f").to_string()
45                    } else {
46                        time_with_zone.format("%a %b %-d %H:%M").to_string()
47                    }
48                }
49                TimeLabelKind::Custom(fmt) => time_with_zone.format(fmt).to_string(),
50            })
51        })
52    }
53
54    /// Converts a UTC `DateTime` into the user's configured timezone and normalizes it to
55    /// `DateTime<FixedOffset>` so downstream formatting can use one concrete type.
56    fn with_user_timezone<T>(
57        &self,
58        datetime: DateTime<chrono::Utc>,
59        formatter: impl FnOnce(DateTime<chrono::FixedOffset>) -> T,
60    ) -> T {
61        let time_with_zone = match self {
62            UserTimezone::Local => datetime.with_timezone(&chrono::Local).fixed_offset(),
63            UserTimezone::Utc => datetime.fixed_offset(),
64        };
65
66        formatter(time_with_zone)
67    }
68
69    /// Formats an already timezone-adjusted timestamp for axis labels.
70    ///
71    /// `timeframe` controls whether output is second-level (`MM:SS`) or minute-level (`HH:MM`).
72    /// At exact midnight for non-sub-10s intervals, this returns the day-of-month (`D`) to
73    /// emphasize date boundaries on the chart.
74    fn format_by_timeframe(
75        datetime: &DateTime<chrono::FixedOffset>,
76        timeframe: exchange::Timeframe,
77    ) -> String {
78        let interval = timeframe.to_milliseconds();
79
80        if interval < 10_000 {
81            datetime.format("%M:%S").to_string()
82        } else if datetime.format("%H:%M").to_string() == "00:00" {
83            datetime.format("%-d").to_string()
84        } else {
85            datetime.format("%H:%M").to_string()
86        }
87    }
88}
89
90impl fmt::Display for UserTimezone {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        match self {
93            UserTimezone::Utc => write!(f, "UTC"),
94            UserTimezone::Local => {
95                let local_offset = chrono::Local::now().offset().local_minus_utc();
96                let hours = local_offset / 3600;
97                let minutes = (local_offset % 3600) / 60;
98                write!(f, "Local (UTC {hours:+03}:{minutes:02})")
99            }
100        }
101    }
102}
103
104impl<'de> Deserialize<'de> for UserTimezone {
105    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
106    where
107        D: serde::Deserializer<'de>,
108    {
109        let timezone_str = String::deserialize(deserializer)?;
110        match timezone_str.to_lowercase().as_str() {
111            "utc" => Ok(UserTimezone::Utc),
112            "local" => Ok(UserTimezone::Local),
113            _ => Err(serde::de::Error::custom("Invalid UserTimezone")),
114        }
115    }
116}
117
118impl Serialize for UserTimezone {
119    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
120    where
121        S: serde::Serializer,
122    {
123        match self {
124            UserTimezone::Utc => serializer.serialize_str("UTC"),
125            UserTimezone::Local => serializer.serialize_str("Local"),
126        }
127    }
128}