Repository: tarkah/tickrs (language: rust)

Issue:
Show x-axis time labels in charts
Hey hey, this is a neat little tool - good stuff! :bow:

It would be immensly helpful to show not only the y-axis labels but also the x-axis time labels; this would make it much more useful.


Discussion / hints:
This is definitely something I've wanted to add. The API gives me the timestamps for each data point so that won't be too hard.

The challenge I see for this one will be figuring out how to format the date/times for each time frame and how many labels are able to fit within the view and make sense as to which column they are actually for.

Are you thinking just a start / middle / end label like on the Y axis? 

Fix patch:
diff --git a/src/cli.rs b/src/cli.rs
index 7139f5a..5ef46f3 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -24,6 +24,9 @@ pub struct Opt {
     #[structopt(short = "t", long, default_value = "1D", possible_values(&["1D", "1W", "1M", "3M", "6M", "1Y", "5Y"]))]
     /// Use specified time frame when starting program and when new stocks are added
     pub time_frame: TimeFrame,
+    #[structopt(short = "x", long)]
+    /// Show x-axis labels
+    pub show_x_labels: bool,
 }
 
 pub fn get_opts() -> Opt {
diff --git a/src/common.rs b/src/common.rs
index 5333147..0fa5247 100644
--- a/src/common.rs
+++ b/src/common.rs
@@ -1,4 +1,5 @@
 use api::{model::ChartData, Range};
+use chrono::{Local, TimeZone, Utc};
 use itertools::izip;
 
 use std::str::FromStr;
@@ -98,6 +99,41 @@ impl TimeFrame {
             TimeFrame::Year5 => Range::Year5,
         }
     }
+
+    pub fn format_time(&self, timestamp: i64) -> String {
+        let utc_date = Utc.timestamp(timestamp, 0);
+        let local_date = utc_date.with_timezone(&Local);
+
+        let fmt = match self {
+            TimeFrame::Day1 => "%H:%M",
+            TimeFrame::Week1 => "%m-%d %H:%M",
+            _ => "%F",
+        };
+
+        local_date.format(fmt).to_string()
+    }
+}
+
+pub struct MarketHours(i64, i64);
+
+impl Default for MarketHours {
+    fn default() -> Self {
+        MarketHours(52200, 75600)
+    }
+}
+
+impl Iterator for MarketHours {
+    type Item = i64;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        if self.0 == self.1 {
+            None
+        } else {
+            let result = Some(self.0);
+            self.0 += 60;
+            result
+        }
+    }
 }
 
 #[derive(Debug, Clone, Copy)]
diff --git a/src/event.rs b/src/event.rs
index 66432b7..8417629 100644
--- a/src/event.rs
+++ b/src/event.rs
@@ -1,7 +1,7 @@
-use crate::app;
 use crate::cleanup_terminal;
 use crate::draw;
 use crate::widget::options;
+use crate::{app, SHOW_X_LABELS};
 
 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
 
@@ -67,6 +67,14 @@ pub fn handle_keys_display_stock<B: Backend>(
                 app.mode = app::Mode::DisplayOptions;
                 draw::draw(&mut terminal, &mut app);
             }
+            KeyCode::Char('x') => {
+                {
+                    let mut show_x_labels = SHOW_X_LABELS.write().unwrap();
+                    *show_x_labels = !*show_x_labels;
+                }
+
+                draw::draw(&mut terminal, &mut app);
+            }
             KeyCode::Tab => {
                 if app.current_tab == app.stocks.len() - 1 {
                     app.current_tab = 0;
diff --git a/src/main.rs b/src/main.rs
index 20dde0e..94b7f3f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -14,6 +14,7 @@ use tui::Terminal;
 
 use std::io::{self, Write};
 use std::panic;
+use std::sync::RwLock;
 use std::thread;
 use std::time::Duration;
 
@@ -34,6 +35,7 @@ lazy_static! {
     pub static ref UPDATE_INTERVAL: u64 = OPTS.update_interval;
     pub static ref TIME_FRAME: TimeFrame = OPTS.time_frame;
     pub static ref REDRAW_REQUEST: (Sender<()>, Receiver<()>) = unbounded();
+    pub static ref SHOW_X_LABELS: RwLock<bool> = RwLock::new(OPTS.show_x_labels);
 }
 
 fn main() {
diff --git a/src/widget/help.rs b/src/widget/help.rs
index 74b0ed3..ad5c87a 100644
--- a/src/widget/help.rs
+++ b/src/widget/help.rs
@@ -27,10 +27,12 @@ Toggle Options Pane:
   - Navigate with arrow keys
 Toggle Summary Pane:
   - s: toggle pane
+Toggle X Axis Labels:
+  - x: toggle labels
 "#;
 
 pub const HELP_WIDTH: u16 = 35;
-pub const HELP_HEIGHT: u16 = 24;
+pub const HELP_HEIGHT: u16 = 26;
 
 #[derive(Copy, Clone)]
 pub struct HelpWidget {}
diff --git a/src/widget/stock.rs b/src/widget/stock.rs
index cefd0d0..82a8416 100644
--- a/src/widget/stock.rs
+++ b/src/widget/stock.rs
@@ -2,7 +2,7 @@ use super::{block, OptionsState};
 use crate::common::*;
 use crate::draw::{add_padding, PaddingDirection};
 use crate::service::{self, Service};
-use crate::TIME_FRAME;
+use crate::{SHOW_X_LABELS, TIME_FRAME};
 
 use api::model::CompanyData;
 
@@ -143,6 +143,45 @@ impl StockState {
         }
     }
 
+    pub fn x_labels(&self, width: u16) -> Vec<String> {
+        let mut labels = vec![];
+
+        let dates = if self.time_frame == TimeFrame::Day1 {
+            MarketHours::default().collect()
+        } else {
+            self.prices().iter().map(|p| p.date).collect::<Vec<_>>()
+        };
+
+        if dates.is_empty() {
+            return labels;
+        }
+
+        let label_len = dates
+            .get(0)
+            .map_or(0, |d| self.time_frame.format_time(*d).len())
+            + 5;
+        let num_labels = width as usize / label_len;
+        let chunk_size = (dates.len() as f32 / (num_labels - 1) as f32).ceil() as usize;
+
+        for (idx, chunk) in dates.chunks(chunk_size).enumerate() {
+            if idx == 0 {
+                labels.push(
+                    chunk
+                        .get(0)
+                        .map_or("".to_string(), |d| self.time_frame.format_time(*d)),
+                );
+            }
+
+            labels.push(
+                chunk
+                    .get(chunk.len() - 1)
+                    .map_or("".to_string(), |d| self.time_frame.format_time(*d)),
+            );
+        }
+
+        labels
+    }
+
     pub fn y_bounds(&self, min: f32, max: f32) -> [f64; 2] {
         [(min - 0.05) as f64, (max + 0.05) as f64]
     }
@@ -182,6 +221,8 @@ impl StatefulWidget for StockWidget {
     fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
         let pct_change = state.pct_change();
 
+        let show_x_labes = SHOW_X_LABELS.read().map_or(false, |l| *l);
+
         let (company_name, currency) = match state.profile.as_ref() {
             Some(profile) => (
                 profile.price.short_name.as_str(),
@@ -261,14 +302,22 @@ impl StatefulWidget for StockWidget {
             let expand_info = [
                 Text::raw("\n\n"),
                 Text::styled(
-                    "Options 'o'\n",
+                    "Options  'o'\n",
                     Style::default().bg(if state.show_options {
                         Color::DarkGray
                     } else {
                         Color::Black
                     }),
                 ),
-                Text::raw("Summary 's'"),
+                Text::raw("Summary  's'\n"),
+                Text::styled(
+                    "X Labels 'x'",
+                    Style::default().bg(if show_x_labes {
+                        Color::DarkGray
+                    } else {
+                        Color::Black
+                    }),
+                ),
             ];
 
             Paragraph::new(company_info.iter())
@@ -310,13 +359,28 @@ impl StatefulWidget for StockWidget {
                 GraphType::Line
             };
 
+            let x_labels = if show_x_labes {
+                state.x_labels(chunks[2].width)
+            } else {
+                vec![]
+            };
+
             Chart::<String, String>::default()
                 .block(
                     Block::default()
                         .borders(Borders::TOP)
                         .border_style(Style::default()),
                 )
-                .x_axis(Axis::default().bounds(state.x_bounds()))
+                .x_axis({
+                    let axis = Axis::default().bounds(state.x_bounds());
+
+                    if show_x_labes {
+                        axis.labels(&x_labels)
+                            .style(Style::default().fg(Color::LightBlue))
+                    } else {
+                        axis
+                    }
+                })
                 .y_axis(
                     Axis::default()
                         .bounds(state.y_bounds(min, max))


Test patch: