Repository: streamlit/streamlit

Issue:
Passing a date in index to st.line_chart defaults to UTC time zone
# Summary
This may not be a Streamlit bug - it may be a Python issue where I need to declare a datetime. When I pass a date through as the index to st.line_chart my graph is then shifted 7 hours back in time from my intended time (e.g. I would want to show value 50 on the graph for midnight on 2019-08-10 and it shows as 5pm for 2019-08-09). I think what is happening is somehow it's doing a UTC offset by default. Would like to

# Steps to reproduce
df = pd.DataFrame({
  'index': [date(2019, 8, 9), date(2019, 8, 10), date(2019, 8, 11), date(2019, 8, 12)],
  'numbers': [10, 50, 30, 40]
})

df

st.line_chart(df.set_index('index'))



## Expected behavior:
I would expect it to plot in the date that I passed through but it's converting it for some reason. Also happens when I use pd.to_datetime() on a dataset. 

## Actual behavior:
Shifts it back in time by 7 hours.

## Is this a regression?
No

# Debug info
Streamlit, version 0.47.4
Python 3.7.2


Discussion:
By default, Vegalite outputs time data in local time: https://vega.github.io/vega-lite/docs/timeunit.html#output

We can fix this by setting `scale: {type: "utc"}` in the chart spec:

```python
chart = (
    getattr(alt.Chart(data), "mark_" + chart_type)()
    .encode(
        alt.X("index", title="", scale=alt.Scale(type="utc")),  # <-- set "scale" to "utc"
        alt.Y("value", title=""),
        alt.Color("variable", title="", type="nominal"),
        alt.Tooltip(["index", "value", "variable"]),
        opacity=opacity,
    )
    .interactive()
)
```

(We'd only want to do this for temporal data, obviously.) But I'm not familiar with altair/vega-lite, so I'm not sure if this is the right solution.
Maybe `st.line_chart` and friends take a new `datetime_display` param that defaults to "local" but can be set to "utc"?

Resolving patch:
diff --git a/e2e/scripts/chart_utc_time.py b/e2e/scripts/chart_utc_time.py
new file mode 100644
--- /dev/null
+++ b/e2e/scripts/chart_utc_time.py
@@ -0,0 +1,27 @@
+from datetime import date
+
+import pandas as pd
+import streamlit as st
+
+df = pd.DataFrame(
+    {
+        "index": [
+            date(2019, 8, 9),
+            date(2019, 8, 10),
+            date(2019, 8, 11),
+            date(2019, 8, 12),
+        ],
+        "numbers": [10, 50, 30, 40],
+    }
+)
+
+df.set_index("index", inplace=True)
+
+# st.area/bar/line_chart all use Altair/Vega-Lite under the hood.
+# By default, Vega-Lite displays time values in the browser's local
+# time zone. In `altair.generate_chart`, we explicitly set the time
+# display to UTC, so that our results are consistent. This test verifies
+# that change!
+st.area_chart(df)
+st.bar_chart(df)
+st.line_chart(df)
diff --git a/lib/streamlit/elements/altair.py b/lib/streamlit/elements/altair.py
--- a/lib/streamlit/elements/altair.py
+++ b/lib/streamlit/elements/altair.py
@@ -18,6 +18,7 @@
 # Python 2/3 compatibility
 from __future__ import absolute_import
 
+from datetime import date
 
 from streamlit.elements.data_frame_proto import convert_anything_to_df
 import streamlit.elements.vega_lite as vega_lite
@@ -25,6 +26,29 @@
 import pandas as pd
 
 
+def _is_date_column(df, name):
+    """True if the column with the given name stores datetime.date values.
+
+    This function just checks the first value in the given column, so
+    it's meaningful only for columns whose values all share the same type.
+
+    Parameters
+    ----------
+    df : pd.DataFrame
+    name : str
+        The column name
+
+    Returns
+    -------
+    bool
+
+    """
+    column = df[name]
+    if column.size == 0:
+        return False
+    return isinstance(column[0], date)
+
+
 def generate_chart(chart_type, data):
     if data is None:
         # Use an empty-ish dict because if we use None the x axis labels rotate
@@ -34,12 +58,10 @@ def generate_chart(chart_type, data):
     if not isinstance(data, pd.DataFrame):
         data = convert_anything_to_df(data)
 
-    n_cols = len(data.columns)
-    
     index_name = data.index.name
     if index_name is None:
-       index_name = "index" 
-        
+        index_name = "index"
+
     data = pd.melt(data.reset_index(), id_vars=[index_name])
 
     if chart_type == "area":
@@ -47,11 +69,21 @@ def generate_chart(chart_type, data):
     else:
         opacity = {"value": 1.0}
 
+    # Set the X and Y axes' scale to "utc" if they contain date values.
+    # This causes time data to be displayed in UTC, rather the user's local
+    # time zone. (By default, vega-lite displays time data in the browser's
+    # local time zone, regardless of which time zone the data specifies:
+    # https://vega.github.io/vega-lite/docs/timeunit.html#output).
+    x_scale = (
+        alt.Scale(type="utc") if _is_date_column(data, index_name) else alt.Undefined
+    )
+    y_scale = alt.Scale(type="utc") if _is_date_column(data, "value") else alt.Undefined
+
     chart = (
         getattr(alt.Chart(data), "mark_" + chart_type)()
         .encode(
-            alt.X(index_name, title=""),
-            alt.Y("value", title=""),
+            alt.X(index_name, title="", scale=x_scale),
+            alt.Y("value", title="", scale=y_scale),
             alt.Color("variable", title="", type="nominal"),
             alt.Tooltip([index_name, "value", "variable"]),
             opacity=opacity,


Test patch:
diff --git a/lib/tests/streamlit/altair_test.py b/lib/tests/streamlit/altair_test.py
--- a/lib/tests/streamlit/altair_test.py
+++ b/lib/tests/streamlit/altair_test.py
@@ -14,18 +14,24 @@
 # limitations under the License.
 
 """st.altair_chart unit test."""
+from datetime import date
+from functools import reduce
 
 import altair as alt
 import json
 import pandas as pd
 
+from streamlit.elements import altair
 from tests import testutil
 import streamlit as st
 
 
-df1 = pd.DataFrame([["A", "B", "C", "D"], [28, 55, 43, 91]], index=["a", "b"]).T
-
-c1 = alt.Chart(df1).mark_bar().encode(x="a", y="b")
+def _deep_get(dictionary, *keys):
+    return reduce(
+        lambda d, key: d.get(key, None) if isinstance(d, dict) else None,
+        keys,
+        dictionary,
+    )
 
 
 class AltairTest(testutil.DeltaGeneratorTestCase):
@@ -33,6 +39,10 @@ class AltairTest(testutil.DeltaGeneratorTestCase):
 
     def test_altair_chart(self):
         """Test that it can be called with no args."""
+        df1 = pd.DataFrame([["A", "B", "C", "D"], [28, 55, 43, 91]], index=["a", "b"]).T
+
+        c1 = alt.Chart(df1).mark_bar().encode(x="a", y="b")
+
         st.altair_chart(c1)
 
         c = self.get_delta_from_queue().new_element.vega_lite_chart
@@ -51,3 +61,23 @@ def test_altair_chart(self):
         self.assertEqual(spec_dict["mark"], "bar")
         self.assertTrue("config" in spec_dict)
         self.assertTrue("encoding" in spec_dict)
+
+    def test_date_column_utc_scale(self):
+        """Test that columns with date values have UTC time scale"""
+        df = pd.DataFrame(
+            {"index": [date(2019, 8, 9), date(2019, 8, 10)], "numbers": [1, 10]}
+        ).set_index("index")
+
+        chart = altair.generate_chart("line", df)
+        st.altair_chart(chart)
+        c = self.get_delta_from_queue().new_element.vega_lite_chart
+        spec_dict = json.loads(c.spec)
+
+        # The x axis should have scale="utc", because it uses date values.
+        x_scale = _deep_get(spec_dict, "encoding", "x", "scale", "type")
+        self.assertEqual(x_scale, "utc")
+
+        # The y axis should _not_ have scale="utc", because it doesn't
+        # use date values.
+        y_scale = _deep_get(spec_dict, "encoding", "y", "scale", "type")
+        self.assertNotEqual(y_scale, "utc")