Repository: biolab/orange3

Issue:
Distributions: binning datetime
**Describe the bug**
Distributions doesn't bin datetime variables correctly.

**To Reproduce**
Steps to reproduce the behavior:
Datasets (Philadelphia Crime or any timeseries data set) - Distributions.

**Orange version:**
3.23.dev

**Expected behavior**
Bin width should not be hidden behind the slider and it should be datetime (i.e. 2019-08-01).
X axis in the plot should be datetime.

**Screenshots**
Example of Kennedy's speeches. Bins are not correctly computed. The final bin contains only 1 item, even though the speeches come in fairly equal intervals between 1960 and 1963. Bin should probably be <1962 and >=1962.

<img width="842" alt="Screen Shot 2019-08-02 at 13 39 08" src="https://user-images.githubusercontent.com/12524972/62448754-c7ea1780-b768-11e9-955c-135066b747ef.png">



Discussion:
To test: 
[kennedy.tab.zip](https://github.com/biolab/orange3/files/3466508/kennedy.tab.zip)

Thanks for the test data!

Just a comment (to self or anybody else who decides to solve this).

**Labels.** Methods `_cont_plot` and `_cont_split_plot` call `self.ploti.getAxis("bottom").setTicks(None)`, which lets pyqtgraph compute the tick labels itself, so it interprets number as numbers, not datetimes. (Note that tooltips are correct!). This should be fixed by setting ticks to values returned by `str_int`, I suppose. Like tooltips do.

**Bins.** `Orange.preprocess.discretize.decimal_binnnings` finds nice bins for numeric variables. You can see they *are* nice here -- 0, -100, -200, -300 * 10^6 ... except that 100 million does not translate into nice time intervals. To fix this. we would need to implement a function `Orange.preprocess.discretize.time_binnnings`, and call it from `OWDistribution` instead of `decimal_binnings` when it has time variable.
After a lot of unsuccessful prototying, I think there is a limited number of reasonable intervals:
- 100, 50, 25, 10, 5, 2, 1 years (labels is a year, without any date)
- 6, 3, 2, 1 months (can span over multiple years; same below) (label is year + month, no day)
- 2, 1 weeks (label is a date, without hours)
- 1 day (label is a date)
- 6, 3, 2, 1 hours (labels is a date + hh:00)
- 30, 15, 10, 5, 1 minutes (date + hh:mm)
- 30, 15, 10, 5, 1 seconds (date + hh:mm:ss)
- parts of seconds to arbitrary precision, using the existing decimal binnings (date + hh:mm:ss.ssss)

Time binning should check which of these produce 2 - 20 bins (or so).

Labels should be formed as described above, with some exceptions:
- if consecutive labels have the same date, the date is shown only for the first one, e.g. if the bin width is 15 minutes, the first label may be "10 Sep 2019 19:30", followed by "23:15", "23:30", "23:45", "11 Seo 2019 00:00", "00:15"
- similarly, if the width is a month or less, if multiple labels have the same year, it is shown only for the first label

Datetime variables may not contain actual dates but, for instance, times of day. If all data is in 1970 (i.e. zero), and the width is in range of months, only months are printed. If month is always January, only a day is printed. If the date is Jan 1, 1970, only hh:mm is printed. If hh:mm is 00:00, date time is treated as continuous variable (because the data is about seconds). I don't know how to formulate the rule for data that refers to days of week -- I don't think we have a good presentation (and Jan 1 1970 was Thursday).

@ajdapretnar?
I love the proposal above. It makes sense.

As for Unix timestamp values, I don't think we support them atm. Just ISO in the form 1970-01-01. Yes, you can 'convert' a numeric variable to datetime, say petal length, but it doesn't look any different to me. It is treated as any numeric variable. Perhaps a check that variable satisfies ISO format? (Perhaps this should be done in the File widget anyway?)

Resolving patch:
diff --git a/Orange/preprocess/discretize.py b/Orange/preprocess/discretize.py
--- a/Orange/preprocess/discretize.py
+++ b/Orange/preprocess/discretize.py
@@ -1,5 +1,9 @@
+import calendar
 import re
-from collections import namedtuple
+import time
+from typing import NamedTuple, List, Union, Callable
+import datetime
+from itertools import count
 
 import numpy as np
 import scipy.sparse as sp
@@ -14,7 +18,8 @@
 from . import _discretize
 
 __all__ = ["EqualFreq", "EqualWidth", "EntropyMDL", "DomainDiscretizer",
-           "decimal_binnings", "get_bins"]
+           "decimal_binnings", "time_binnings", "short_time_units",
+           "BinDefinition"]
 
 
 class Discretizer(Transformation):
@@ -174,14 +179,52 @@ def _split_eq_width(self, min, max):
         return [min + (i + 1) * dif for i in range(self.n - 1)]
 
 
-BinDefinition = namedtuple("BinDefinition", ("start", "nbins", "width"))
+class BinDefinition(NamedTuple):
+    thresholds: np.ndarray  # thresholds, including the top
+    labels: List[str]  # friendly-formatted thresholds
+    short_labels: List[str]  # shorter labels (e.g. simplified dates)
+    width: Union[float, None]  # widths, if uniform; otherwise None
+    width_label: str  # friendly-formatted width (e.g. '50' or '2 weeks')
+
+
+# NamedTupleMeta doesn't allow to define __new__ so we need a subclass
+# Name of the class has to be the same to match the namedtuple name
+# pylint: disable=function-redefined
+class BinDefinition(BinDefinition):
+    def __new__(cls, thresholds, labels="%g",
+                short_labels=None, width=None, width_label=""):
+
+        def get_labels(fmt, default=None):
+            if fmt is None:
+                return default
+            if isinstance(fmt, str):
+                return [fmt % x for x in thresholds]
+            elif isinstance(fmt, Callable):
+                return [fmt(x) for x in thresholds]
+            else:
+                return fmt
+
+        labels = get_labels(labels)
+        short_labels = get_labels(short_labels, labels)
+        if not width_label and width is not None:
+            width_label = f"{width:g}"
+        return super().__new__(
+            cls, thresholds, labels, short_labels, width, width_label)
+
+    @property
+    def start(self) -> float:
+        return self.thresholds[0]
+
+    @property
+    def nbins(self) -> int:
+        return len(self.thresholds) - 1
 
 
 def decimal_binnings(
         data, *, min_width=0, min_bins=2, max_bins=50,
-        min_unique=5, add_unique=None,
-        factors=(20, 10, 5, 2, 1, 0.5, 0.25, 0.2, 0.1, 0.05, 0.025, 0.02, 0.01),
-        return_defs=False):
+        min_unique=5, add_unique=0,
+        factors=(0.01, 0.02, 0.025, 0.05, 0.1, 0.2, 0.25, 0.5, 1, 2, 5, 10, 20),
+        label_fmt="%g"):
     """
     Find a set of nice splits of data into bins
 
@@ -218,14 +261,16 @@ def decimal_binnings(
             the function returns a single binning that matches that values in
             the data
         add_unique (int):
-            similar to `min_unique` except that such bins are added to the list
+            similar to `min_unique` except that such bins are added to the list;
+            set to 0 to disable
         factors (list of float):
             The factors with which the scaling is multiplied. Default is
-            `(20, 10, 5, 2, 1, 0.5, 0.25, 0.2, 0.1, 0.05, 0.025, 0.02, 0.01)`,
+            `(0.01, 0.02, 0.025, 0.05, 0.1, 0.2, 0.25, 0.5, 1, 2, 5, 10, 20)`,
             so if scaling is 1000, considered bin widths are 20000, 10000,
             5000, 2000, 1000, 500, 250, 200, 100, 50, 25, 20 and 10.
-        return_defs (bool): If set to `True`, the function returns a list of
-            instances of `BinDefinition`, otherwise a list of bin boundaries.
+        label_fmt (str or Callable):
+            A format string (default: "%g") used for threshold labels,
+            or a function for formatting thresholds (e.g. var.str_val)
 
     Returns:
         bin_boundaries (list of np.ndarray): a list of bin boundaries,
@@ -245,29 +290,16 @@ def decimal_binnings(
 
             This is returned if `return_defs` is `False`.
     """
-    def unique_bins():
-        if len(unique) >= 2:
-            # make the last bin the same width as the one before
-            last_boundary = 2 * unique[-1] - unique[-2]
-        else:
-            last_boundary = unique[0] + 1
-        return BinDefinition(
-            unique[0], len(unique), np.hstack((unique, [last_boundary])))
+    bins = []
 
-    unique = np.unique(data)
-    unique = unique[np.isfinite(unique)]
-    if not unique.size:
-        raise ValueError("no valid (non-nan) data")
-    mn, mx = unique[0], unique[-1]
-    if mn == mx or len(unique) <= min_unique:
-        bins = unique_bins()
-        if not return_defs:
-            bins = get_bins(bins)
-        return [bins]
+    mn, mx, unique = _min_max_unique(data)
+    if len(unique) <= max(min_unique, add_unique):
+        bins.append(BinDefinition(_unique_thresholds(unique), label_fmt))
+        if len(unique) <= min_unique:
+            return bins
 
     diff = mx - mn
     f10 = 10 ** -np.floor(np.log10(diff))
-    bins = []
     max_bins = min(max_bins, len(unique))
     for f in factors:
         width = f / f10
@@ -278,40 +310,180 @@ def unique_bins():
         nbins = np.round((mx_ - mn_) / width)
         if min_bins <= nbins <= max_bins \
                 and (not bins or bins[-1].nbins != nbins):
-            bins.append(BinDefinition(mn_, nbins, width))
+            bin_def = BinDefinition(mn_ + width * np.arange(nbins + 1),
+                                    label_fmt, None, width)
+            bins.append(bin_def)
+    return bins
 
-    if add_unique is not None and len(unique) <= add_unique:
-        if bins and bins[-1].nbins == len(unique):
-            del bins[-1]
-        bins.append(unique_bins())
-        if len(unique) < min_unique:
-            del bins[:-1]
 
-    if not return_defs:
-        bins = [get_bins(bin) for bin in bins]
+def time_binnings(data, *, min_bins=2, max_bins=50, min_unique=5, add_unique=0):
+    """
+    Find a set of nice splits of time variable data into bins
 
-    return bins
+    The function considers bin widths of
 
+    - 1, 5, 10, 15, 30 seconds.
+    - 1, 5, 10, 15, 30 minutes,
+    - 1, 2, 3, 6, 12 hours,
+    - 1 day,
+    - 1, 2 weeks,
+    - 1, 2, 3, 6 months,
+    - 1, 2, 5, 10, 25, 50, 100 years,
+
+    and returns those that yield between `min_bins` and `max_bins` intervals.
 
-def get_bins(bin_def: BinDefinition):
-    """
-    Return a `np.ndarray` corresponding to interval
     Args:
-        bin_def (BinDefinition):
-            definition of bins. a named tuple containing the beginning of the
-            first bin (`start`), number of bins (`nbins`) and their widths
-            (`width`). The last value can also be a `nd.array` with `nbins + 1`
-            elements, in which case the function returns this as a result.
+        data (np.ndarray):
+            vector of data points; values may repeat, and nans and infs are
+            filtered out.
+        min_bins (int): minimal number of bins
+        max_bins (int):
+            maximal number of bins; the number of bins will never exceed the
+            number of unique values
 
     Returns:
-        bin boundaries (np.ndarray):
-        bin boundaries including the top boundary of the last interval, hence
-        the list size equals `bin_def.nbins + 1`. This array matches the
-        `bin` argument of `numpy.histogram`.
+        bin_boundaries (list): a list of possible binning.
+            Each element of `bin_boundaries` is a tuple consisting of a label
+            describing the bin size (e.g. `2 weeks`) and a list of thresholds.
+            Thresholds are given as pairs
+            (number_of_seconds_since_epoch, label).
     """
-    if isinstance(bin_def.width, np.ndarray):
-        return bin_def.width
-    return bin_def.start + bin_def.width * np.arange(bin_def.nbins + 1)
+    mn, mx, unique = _min_max_unique(data)
+    mn, mx = time.gmtime(mn), time.gmtime(mx)
+    bins = []
+    if len(unique) <= max(min_unique, add_unique):
+        bins.append(_unique_time_bins(unique))
+    if len(unique) > min_unique:
+        bins += _time_binnings(mn, mx, min_bins + 1, max_bins + 1)
+    return bins
+
+
+def _time_binnings(mn, mx, min_pts, max_pts):
+    yfmt = "%y " if mn.tm_year >= 1950 else "%Y "
+    bins = []
+    for place, step, fmt, unit in (
+            [(5, x, "%H:%M:%S", "second") for x in (1, 5, 10, 15, 30)] +
+            [(4, x, "%b %d %H:%M", "minute") for x in (1, 5, 10, 15, 30)] +
+            [(3, x, yfmt + "%b %d %H:%M", "hour") for x in (1, 2, 3, 6, 12)] +
+            [(2, 1, yfmt + "%b %d", "day")] +
+            [(2, x, yfmt + "%b %d", "week") for x in (7, 14)] +
+            [(1, x, yfmt + "%b", "month") for x in (1, 2, 3, 6)] +
+            [(0, x, yfmt.strip(), "year") for x in (1, 2, 5, 10, 25, 50, 100)]):
+        times = _time_range(mn, mx, place, step, min_pts, max_pts)
+        if not times:
+            continue
+        times = [time.struct_time(t + (0, 0, 0)) for t in times]
+        thresholds = [calendar.timegm(t) for t in times]
+        labels = [time.strftime(fmt, t) for t in times]
+        short_labels = _simplified_labels(labels)
+        if place == 2 and step >= 7:
+            unit_label = f"{step // 7} week{'s' * (step > 7)}"
+        else:
+            unit_label = f"{step} {unit}{'s' * (step > 1)}"
+        new_bins = BinDefinition(
+            thresholds, labels, short_labels, None, unit_label)
+        if not bins or new_bins.nbins != bins[-1].nbins:
+            bins.append(new_bins)
+    return bins
+
+
+# datetime + deltatime is not very useful here because deltatime is
+# given a number of days, not years or months, so it doesn't allow
+# for specifying a step of 1 month
+def _time_range(start, end, place, step, min_pts, max_pts,
+                _zeros=(0, 1, 1, 0, 0, 0)):
+    if place == 2 and step % 7 == 0:
+        startd = datetime.date(*start[:3])
+        startd -= datetime.timedelta(days=-startd.weekday())
+        start = [startd.year, startd.month, startd.day, 0, 0, 0]
+    else:
+        start = list(
+            start[:place]
+            + ((start[place] - _zeros[place]) // step * step + _zeros[place], )
+            + _zeros[place + 1:])
+    end = list(end[:place + 1] + _zeros[place + 1:])
+    s = [tuple(start)]
+    for _ in range(max_pts - 1):
+        start[place] += step
+        if place >= 3:  # hours, minutes, seconds
+            for pos, maxval in enumerate((60, 60, 24), start=1):
+                if start[-pos] >= maxval:
+                    start[-pos - 1] += 1
+                    start[-pos] %= maxval
+        if place >= 2:
+            md = _month_days(*start[:2])
+            if start[2] > md:
+                start[1] += 1
+                start[2] %= md
+        if start[1] > 12:
+            start[0] += 1
+            start[1] %= 12
+        s.append(tuple(start))
+        if start > end:
+            return s if len(s) >= min_pts else None
+    return None
+
+
+def _month_days(year, month,
+                _md=(None, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)):
+    return _md[month] + (
+        month == 2 and (year % 400 == 0 or year % 4 == 0 and year % 100 != 0))
+
+
+def _simplified_labels(labels):
+    labels = labels[:]
+    to_remove = "42"
+    while True:
+        firsts = {f for f, *_ in (lab.split() for lab in labels)}
+        if len(firsts) > 1:
+            break
+        to_remove = firsts.pop()
+        flen = len(to_remove) + 1
+        if any(len(lab) == flen for lab in labels):
+            break
+        labels = [lab[flen:] for lab in labels]
+    for i in range(len(labels) - 1, 0, -1):
+        for k, c, d in zip(count(), labels[i].split(), labels[i - 1].split()):
+            if c != d:
+                labels[i] = " ".join(labels[i].split()[k:])
+                break
+    # If the last thing removed were month names and the labels continues with
+    # hours, keep month name in the first label; "08 12:29" looks awkward.
+    if not to_remove[0].isdigit() and ":" in labels[0]:
+        labels[0] = f"{to_remove} {labels[0]}"
+    return labels
+
+
+def _unique_time_bins(unique):
+    times = [time.gmtime(x) for x in unique]
+    fmt = f'{"%y " if times[0][0] >= 1950 else "%Y "} %b %d'
+    fmt += " %H:%M" * (len({t[2:] for t in times}) > 1)
+    fmt += ":%S" * bool(np.all(unique % 60 == 0))
+    labels = [time.strftime(fmt, x) for x in times]
+    short_labels = _simplified_labels(labels)
+    return BinDefinition(_unique_thresholds(unique), labels, short_labels)
+
+
+def _unique_thresholds(unique):
+    if len(unique) >= 2:
+        # make the last bin the same width as the one before
+        last_boundary = 2 * unique[-1] - unique[-2]
+    else:
+        last_boundary = unique[0] + 1
+    return np.hstack((unique, [last_boundary]))
+
+
+def _min_max_unique(data):
+    unique = np.unique(data)
+    unique = unique[np.isfinite(unique)]
+    if not unique.size:
+        raise ValueError("no valid (non-nan) data")
+    return unique[0], unique[-1], unique
+
+
+short_time_units = dict(seconds="sec", minutes="min", hours="hrs",
+                        weeks="wks", months="mon", years="yrs",
+                        second="sec", minute="min", month="mon")
 
 
 # noinspection PyPep8Naming
diff --git a/Orange/widgets/unsupervised/owsom.py b/Orange/widgets/unsupervised/owsom.py
--- a/Orange/widgets/unsupervised/owsom.py
+++ b/Orange/widgets/unsupervised/owsom.py
@@ -13,7 +13,7 @@
     QGraphicsPathItem
 
 from Orange.data import Table, Domain
-from Orange.preprocess import decimal_binnings
+from Orange.preprocess import decimal_binnings, time_binnings
 from Orange.projection.som import SOM
 
 from Orange.widgets import gui
@@ -226,7 +226,7 @@ def __init__(self):
         self.data = self.cont_x = None
         self.cells = self.member_data = None
         self.selection = None
-        self.colors = self.thresholds = None
+        self.colors = self.thresholds = self.bin_labels = None
 
         box = gui.vBox(self.controlArea, box="SOM")
         shape = gui.comboBox(
@@ -374,7 +374,7 @@ def clear(self):
         self.data = self.cont_x = None
         self.cells = self.member_data = None
         self.attr_color = None
-        self.colors = self.thresholds = None
+        self.colors = self.thresholds = self.bin_labels = None
         if self.elements is not None:
             self.scene.removeItem(self.elements)
             self.elements = None
@@ -828,13 +828,18 @@ def update_output(self):
 
     def set_color_bins(self):
         if self.attr_color is None:
-            self.thresholds = self.colors = None
+            self.thresholds = self.bin_labels = self.colors = None
         elif self.attr_color.is_discrete:
-            self.thresholds = None
+            self.thresholds = self.bin_labels = None
             self.colors = [QColor(*color) for color in self.attr_color.colors]
         else:
             col = self.data.get_column_view(self.attr_color)[0].astype(float)
-            self.thresholds = decimal_binnings(col, min_bins=4)[0][1:-1]
+            if self.attr_color.is_time:
+                binning = time_binnings(col, min_bins=4)[-1]
+            else:
+                binning = decimal_binnings(col, min_bins=4)[-1]
+            self.thresholds = binning.thresholds[1:-1]
+            self.bin_labels = (binning.labels[1:-1], binning.short_labels[1:-1])
             palette = ContinuousPaletteGenerator(*self.attr_color.colors)
             nbins = len(self.thresholds) + 1
             self.colors = [palette[i / (nbins - 1)] for i in range(nbins)]
@@ -872,12 +877,11 @@ def create_legend(self):
         self.set_legend_pos()
 
     def _bin_names(self):
-        sval = self.attr_color.repr_val
+        labels, short_labels = self.bin_labels
         return \
-            [f"< {sval(self.thresholds[0])}"] \
-            + [f"{sval(x)} - {sval(y)}"
-               for x, y in zip(self.thresholds, self.thresholds[1:])] \
-            + [f"≥ {sval(self.thresholds[-1])}"]
+            [f"< {labels[0]}"] \
+            + [f"{x} - {y}" for x, y in zip(labels, short_labels[1:])] \
+            + [f"≥ {labels[-1]}"]
 
     def set_legend_pos(self):
         if self.legend is None:
diff --git a/Orange/widgets/visualize/owdistributions.py b/Orange/widgets/visualize/owdistributions.py
--- a/Orange/widgets/visualize/owdistributions.py
+++ b/Orange/widgets/visualize/owdistributions.py
@@ -1,4 +1,4 @@
-from functools import partial
+from functools import partial, reduce
 from itertools import count, groupby, repeat
 from xml.sax.saxutils import escape
 
@@ -11,7 +11,8 @@
 import pyqtgraph as pg
 
 from Orange.data import Table, DiscreteVariable, ContinuousVariable
-from Orange.preprocess.discretize import decimal_binnings, get_bins
+from Orange.preprocess.discretize import decimal_binnings, time_binnings, \
+    short_time_units
 from Orange.statistics import distribution, contingency
 from Orange.widgets import gui, settings
 from Orange.widgets.utils.annotated_data import \
@@ -212,6 +213,25 @@ def pdf(x, a, sigma=1, weights=None):
         return ash
 
 
+class ElidedAxisNoUnits(ElidedLabelsAxis):
+    def __init__(self, orientation, pen=None, linkView=None, parent=None,
+                 maxTickLength=-5, showValues=True):
+        self.show_unit = False
+        self.tick_dict = {}
+        super().__init__(orientation, pen, linkView, parent, maxTickLength,
+                         showValues)
+
+    def setShowUnit(self, show_unit):
+        self.show_unit = show_unit
+
+    def labelString(self):
+        if self.show_unit:
+            return super().labelString()
+
+        style = ';'.join(f"{k}: {v}" for k, v in self.labelStyle.items())
+        return f"<span style='{style}'>{self.labelText}</span>"
+
+
 class OWDistributions(OWWidget):
     name = "Distributions"
     description = "Display value distributions of a data feature in a graph."
@@ -274,7 +294,6 @@ def __init__(self):
         self.bar_items = []
         self.curve_descriptions = None
         self.binnings = []
-        self.bin_widths = []
 
         self.last_click_idx = None
         self.drag_operation = self.DragNone
@@ -346,7 +365,7 @@ def add_new_plot(zvalue):
         self.mainArea.layout().addWidget(self.plotview)
         self.ploti = pg.PlotItem(
             enableMenu=False, enableMouse=False,
-            axisItems={"bottom": ElidedLabelsAxis("bottom")})
+            axisItems={"bottom": ElidedAxisNoUnits("bottom")})
         self.plot = self.ploti.vb
         self.plot.setMouseEnabled(False, False)
         self.ploti.hideButtons()
@@ -438,11 +457,13 @@ def _set_smoothing_visibility(self):
             self.Fitters[self.fitted_distribution][1] is AshCurve)
 
     def _set_bin_width_slider_label(self):
-        text = ""
         if self.number_of_bins < len(self.binnings):
-            width = self.bin_widths[self.number_of_bins]
-            if isinstance(width, (int, float)):
-                text = f"{width:g}"
+            text = reduce(
+                lambda s, rep: s.replace(*rep),
+                short_time_units.items(),
+                self.binnings[self.number_of_bins].width_label)
+        else:
+            text = ""
         self.bin_width_label.setText(text)
 
     def _on_show_probabilities_changed(self):
@@ -511,6 +532,7 @@ def _set_axis_names(self):
         assert self.is_valid  # called only from replot, so assumes data is OK
         bottomaxis = self.ploti.getAxis("bottom")
         bottomaxis.setLabel(self.var and self.var.name)
+        bottomaxis.setShowUnit(not (self.var and self.var.is_time))
 
         leftaxis = self.ploti.getAxis("left")
         if self.show_probs and self.cvar:
@@ -577,9 +599,10 @@ def _disc_split_plot(self):
                     var.values[i], np.sum(freqs), total, gvalues, freqs))
 
     def _cont_plot(self):
-        self.ploti.getAxis("bottom").setTicks(None)
+        self._set_cont_ticks()
         data = self.valid_data
-        y, x = np.histogram(data, bins=self.binnings[self.number_of_bins])
+        y, x = np.histogram(
+            data, bins=self.binnings[self.number_of_bins].thresholds)
         total = len(data)
         colors = [QColor(0, 128, 255)]
         if self.fitted_distribution:
@@ -603,9 +626,10 @@ def _cont_plot(self):
                 [QColor(0, 0, 0)], (1,))
 
     def _cont_split_plot(self):
-        self.ploti.getAxis("bottom").setTicks(None)
+        self._set_cont_ticks()
         data = self.valid_data
-        _, bins = np.histogram(data, bins=self.binnings[self.number_of_bins])
+        _, bins = np.histogram(
+            data, bins=self.binnings[self.number_of_bins].thresholds)
         gvalues = self.cvar.values
         varcolors = [QColor(*col) for col in self.cvar.colors]
         if self.fitted_distribution:
@@ -641,6 +665,26 @@ def _cont_split_plot(self):
             self._plot_approximations(bins[0], bins[-1], fitters, varcolors,
                                       prior_sizes / len(data))
 
+    def _set_cont_ticks(self):
+        axis = self.ploti.getAxis("bottom")
+        if self.var and self.var.is_time:
+            binning = self.binnings[self.number_of_bins]
+            labels = np.array(binning.short_labels)
+            thresholds = np.array(binning.thresholds)
+            lengths = np.array([len(lab) for lab in labels])
+            slengths = set(lengths)
+            if len(slengths) == 1:
+                ticks = [list(zip(thresholds[::2], labels[::2])),
+                         list(zip(thresholds[1::2], labels[1::2]))]
+            else:
+                ticks = []
+                for length in sorted(slengths, reverse=True):
+                    idxs = lengths == length
+                    ticks.append(list(zip(thresholds[idxs], labels[idxs])))
+            axis.setTicks(ticks)
+        else:
+            axis.setTicks(None)
+
     def _fit_approximation(self, y):
         def join_pars(pairs):
             strv = self.var.str_val
@@ -661,7 +705,7 @@ def str_params():
             return None, None
         _, dist, names, str_names = self.Fitters[self.fitted_distribution]
         fitted = dist.fit(y)
-        params = {name: val for name, val in zip(names, fitted)}
+        params = dict(zip(names, fitted))
         return partial(dist.pdf, **params), str_params()
 
     def _plot_approximations(self, x0, x1, fitters, colors, prior_probs):
@@ -743,19 +787,22 @@ def _display_legend(self):
     # Bins
 
     def recompute_binnings(self):
-        self.binnings = []
-        self.bin_widths = []
-        max_bins = 0
         if self.is_valid and self.var.is_continuous:
             # binning is computed on valid var data, ignoring any cvar nans
             column = self.data.get_column_view(self.var)[0].astype(float)
             if np.any(np.isfinite(column)):
-                binnings = decimal_binnings(
-                    column, min_width=self.min_var_resolution(self.var),
-                    add_unique=10, min_unique=5, return_defs=True)[::-1]
-                self.binnings = [get_bins(bin) for bin in binnings]
-                self.bin_widths = [bin.width for bin in binnings]
+                if self.var.is_time:
+                    self.binnings = time_binnings(column, min_unique=5)
+                    self.bin_width_label.setFixedWidth(45)
+                else:
+                    self.binnings = decimal_binnings(
+                        column, min_width=self.min_var_resolution(self.var),
+                        add_unique=10, min_unique=5)
+                    self.bin_width_label.setFixedWidth(35)
                 max_bins = len(self.binnings) - 1
+        else:
+            self.binnings = []
+            max_bins = 0
 
         self.controls.number_of_bins.setMaximum(max_bins)
         self.number_of_bins = min(


Test patch:
diff --git a/Orange/preprocess/tests/__init__.py b/Orange/preprocess/tests/__init__.py
new file mode 100644
diff --git a/Orange/preprocess/tests/test_discretize.py b/Orange/preprocess/tests/test_discretize.py
new file mode 100644
--- /dev/null
+++ b/Orange/preprocess/tests/test_discretize.py
@@ -0,0 +1,694 @@
+# File contains some long lines; breaking them would decrease readability
+# pylint: disable=line-too-long
+import unittest
+from time import struct_time, mktime
+
+import numpy as np
+from Orange.preprocess.discretize import \
+    _time_binnings, time_binnings, BinDefinition
+
+
+# pylint: disable=redefined-builtin
+def create(year=1970, month=1, day=1, hour=0, min=0, sec=0):
+    return struct_time((year, month, day, hour, min, sec, 0, 0, 0))
+
+
+class TestTimeBinning(unittest.TestCase):
+    def setUp(self):
+        self.dates = [mktime(x) for x in
+            [(1975, 6, 9, 10, 0, 0, 0, 161, 0),
+             (1975, 6, 9, 10, 50, 0, 0, 161, 0),
+             (1975, 6, 9, 11, 40, 0, 0, 161, 0),
+             (1975, 6, 9, 12, 30, 0, 0, 161, 0),
+             (1975, 6, 9, 13, 20, 0, 0, 161, 0),
+             (1975, 6, 9, 14, 10, 0, 0, 161, 0)]]
+
+    def test_binning(self):
+        def testbin(start, end):
+            bins = _time_binnings(create(*start), create(*end), 3, 51)
+            return [(bin.width_label, bin.short_labels, bin.thresholds)
+                    for bin in reversed(bins)]
+
+        self.assertEqual(
+            testbin((1975, 4, 2), (1989, 3, 1)),
+            [('10 years',
+              ['70', '80', '90'],
+              [0, 315532800, 631152000]),
+             ('5 years',
+              ['75', '80', '85', '90'],
+              [157766400, 315532800, 473385600, 631152000]),
+             ('2 years',
+              ['74', '76', '78', '80', '82', '84', '86', '88', '90'],
+              [126230400, 189302400, 252460800, 315532800, 378691200, 441763200,
+               504921600, 567993600, 631152000]),
+             ('1 year',
+              ['75', '76', '77', '78', '79', '80', '81', '82', '83', '84', '85',
+               '86', '87', '88', '89', '90'],
+              [157766400, 189302400, 220924800, 252460800, 283996800, 315532800,
+               347155200, 378691200, 410227200, 441763200, 473385600, 504921600,
+               536457600, 567993600, 599616000, 631152000]),
+             ('6 months',
+              ['75 Jan', 'Jul', '76 Jan', 'Jul', '77 Jan', 'Jul', '78 Jan',
+               'Jul', '79 Jan', 'Jul', '80 Jan', 'Jul', '81 Jan', 'Jul',
+               '82 Jan', 'Jul', '83 Jan', 'Jul', '84 Jan', 'Jul', '85 Jan',
+               'Jul', '86 Jan', 'Jul', '87 Jan', 'Jul', '88 Jan', 'Jul',
+               '89 Jan', 'Jul'],
+              [157766400, 173404800, 189302400, 205027200, 220924800, 236563200,
+               252460800, 268099200, 283996800, 299635200, 315532800, 331257600,
+               347155200, 362793600, 378691200, 394329600, 410227200, 425865600,
+               441763200, 457488000, 473385600, 489024000, 504921600, 520560000,
+               536457600, 552096000, 567993600, 583718400, 599616000,
+               615254400])]
+        )
+
+        self.assertEqual(
+            testbin((1975, 4, 2), (1978, 3, 1)),
+            [('2 years',
+              ['74', '76', '78', '80'],
+              [126230400, 189302400, 252460800, 315532800]),
+             ('1 year',
+              ['75', '76', '77', '78', '79'],
+              [157766400, 189302400, 220924800, 252460800, 283996800]),
+             ('6 months',
+              ['75 Jan', 'Jul',
+               '76 Jan', 'Jul',
+               '77 Jan', 'Jul',
+               '78 Jan', 'Jul'],
+              [157766400, 173404800, 189302400, 205027200, 220924800, 236563200,
+               252460800, 268099200]),
+             ('3 months',
+              ['75 Apr', 'Jul', 'Oct',
+               '76 Jan', 'Apr', 'Jul', 'Oct',
+               '77 Jan', 'Apr', 'Jul', 'Oct',
+               '78 Jan', 'Apr'],
+              [165542400, 173404800, 181353600, 189302400, 197164800, 205027200,
+               212976000, 220924800, 228700800, 236563200, 244512000, 252460800,
+               260236800]),
+             ('2 months',
+              ['75 Mar', 'May', 'Jul', 'Sep', 'Nov',
+               '76 Jan', 'Mar', 'May', 'Jul', 'Sep', 'Nov',
+               '77 Jan', 'Mar', 'May', 'Jul', 'Sep', 'Nov',
+               '78 Jan', 'Mar', 'May'],
+              [162864000, 168134400, 173404800, 178761600, 184032000, 189302400,
+               194486400, 199756800, 205027200, 210384000, 215654400, 220924800,
+               226022400, 231292800, 236563200, 241920000, 247190400, 252460800,
+               257558400, 262828800]),
+             ('1 month',
+              ['75 Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
+               '76 Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
+               '77 Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
+               '78 Jan', 'Feb', 'Mar', 'Apr'],
+              [165542400, 168134400, 170812800, 173404800, 176083200, 178761600,
+               181353600, 184032000, 186624000, 189302400, 191980800, 194486400,
+               197164800, 199756800, 202435200, 205027200, 207705600, 210384000,
+               212976000, 215654400, 218246400, 220924800, 223603200, 226022400,
+               228700800, 231292800, 233971200, 236563200, 239241600, 241920000,
+               244512000, 247190400, 249782400, 252460800, 255139200, 257558400,
+               260236800])]
+        )
+
+        self.assertEqual(
+            testbin((1975, 12, 2), (1976, 1, 3)),
+            [('1 month',
+              ['75 Dec', '76 Jan', 'Feb'],
+              [186624000, 189302400, 191980800]),
+             ('2 weeks',
+              ['75 Dec 03', '17', '31', '76 Jan 14'],
+              [186796800, 188006400, 189216000, 190425600]),
+             ('1 week',
+              ['75 Dec 03', '10', '17', '24', '31',
+               '76 Jan 07'],
+              [186796800, 187401600, 188006400, 188611200, 189216000,
+               189820800]),
+             ('1 day',
+              ['75 Dec 02', '03', '04', '05', '06', '07', '08', '09', '10',
+               '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21',
+               '22', '23', '24', '25', '26', '27', '28', '29', '30', '31',
+               '76 Jan 01', '02', '03', '04'],
+              [186710400, 186796800, 186883200, 186969600, 187056000, 187142400,
+               187228800, 187315200, 187401600, 187488000, 187574400, 187660800,
+               187747200, 187833600, 187920000, 188006400, 188092800, 188179200,
+               188265600, 188352000, 188438400, 188524800, 188611200, 188697600,
+               188784000, 188870400, 188956800, 189043200, 189129600, 189216000,
+               189302400, 189388800, 189475200, 189561600])]
+        )
+
+        self.assertEqual(
+            testbin((1975, 12, 25), (1976, 1, 3)),
+            [('1 month',
+              ['75 Dec', '76 Jan', 'Feb'],
+              [186624000, 189302400, 191980800]),
+             ('1 day',
+              ['75 Dec 25', '26', '27', '28', '29', '30', '31',
+               '76 Jan 01', '02', '03', '04'],
+              [188697600, 188784000, 188870400, 188956800, 189043200, 189129600,
+               189216000, 189302400, 189388800, 189475200, 189561600]),
+             ('12 hours',
+              ['75 Dec 25 00:00', '12:00',
+               '26 00:00', '12:00',
+               '27 00:00', '12:00',
+               '28 00:00', '12:00',
+               '29 00:00', '12:00',
+               '30 00:00', '12:00',
+               '31 00:00', '12:00',
+               '76 Jan 01 00:00', '12:00',
+               '02 00:00', '12:00',
+               '03 00:00', '12:00'],
+              [188697600, 188740800, 188784000, 188827200, 188870400, 188913600,
+               188956800, 189000000, 189043200, 189086400, 189129600, 189172800,
+               189216000, 189259200, 189302400, 189345600, 189388800, 189432000,
+               189475200, 189518400]),
+             ('6 hours',
+              ['75 Dec 25 00:00', '06:00', '12:00', '18:00',
+               '26 00:00', '06:00', '12:00', '18:00',
+               '27 00:00', '06:00', '12:00', '18:00',
+               '28 00:00', '06:00', '12:00', '18:00',
+               '29 00:00', '06:00', '12:00', '18:00',
+               '30 00:00', '06:00', '12:00', '18:00',
+               '31 00:00', '06:00', '12:00', '18:00',
+               '76 Jan 01 00:00', '06:00', '12:00', '18:00',
+               '02 00:00', '06:00', '12:00', '18:00',
+               '03 00:00', '06:00'],
+              [188697600, 188719200, 188740800, 188762400, 188784000, 188805600,
+               188827200, 188848800, 188870400, 188892000, 188913600, 188935200,
+               188956800, 188978400, 189000000, 189021600, 189043200, 189064800,
+               189086400, 189108000, 189129600, 189151200, 189172800, 189194400,
+               189216000, 189237600, 189259200, 189280800, 189302400, 189324000,
+               189345600, 189367200, 189388800, 189410400, 189432000, 189453600,
+               189475200, 189496800])]
+        )
+
+        self.assertEqual(
+            testbin((1975, 12, 29), (1976, 1, 3)),
+            [('1 month',
+              ['75 Dec', '76 Jan', 'Feb'],
+              [186624000, 189302400, 191980800]),
+             ('1 day',
+              ['75 Dec 29', '30', '31',
+               '76 Jan 01', '02', '03', '04'],
+              [189043200, 189129600, 189216000, 189302400, 189388800, 189475200,
+               189561600]),
+             ('12 hours',
+              ['75 Dec 29 00:00', '12:00',
+               '30 00:00', '12:00',
+               '31 00:00', '12:00',
+               '76 Jan 01 00:00', '12:00',
+               '02 00:00', '12:00',
+               '03 00:00', '12:00'],
+              [189043200, 189086400, 189129600, 189172800, 189216000, 189259200,
+               189302400, 189345600, 189388800, 189432000, 189475200,
+               189518400]),
+             ('6 hours',
+              ['75 Dec 29 00:00', '06:00', '12:00', '18:00',
+               '30 00:00', '06:00', '12:00', '18:00',
+               '31 00:00', '06:00', '12:00', '18:00',
+               '76 Jan 01 00:00', '06:00', '12:00', '18:00',
+               '02 00:00', '06:00', '12:00', '18:00',
+               '03 00:00', '06:00'],
+              [189043200, 189064800, 189086400, 189108000, 189129600, 189151200,
+               189172800, 189194400, 189216000, 189237600, 189259200, 189280800,
+               189302400, 189324000, 189345600, 189367200, 189388800, 189410400,
+               189432000, 189453600, 189475200, 189496800]),
+             ('3 hours',
+              ['75 Dec 29 00:00', '03:00', '06:00', '09:00', '12:00', '15:00', '18:00', '21:00',
+               '30 00:00', '03:00', '06:00', '09:00', '12:00', '15:00', '18:00', '21:00',
+               '31 00:00', '03:00', '06:00', '09:00', '12:00', '15:00', '18:00', '21:00',
+               '76 Jan 01 00:00', '03:00', '06:00', '09:00', '12:00', '15:00', '18:00', '21:00',
+               '02 00:00', '03:00', '06:00', '09:00', '12:00', '15:00', '18:00', '21:00',
+               '03 00:00', '03:00'],
+              [189043200, 189054000, 189064800, 189075600, 189086400, 189097200,
+               189108000, 189118800, 189129600, 189140400, 189151200, 189162000,
+               189172800, 189183600, 189194400, 189205200, 189216000, 189226800,
+               189237600, 189248400, 189259200, 189270000, 189280800, 189291600,
+               189302400, 189313200, 189324000, 189334800, 189345600, 189356400,
+               189367200, 189378000, 189388800, 189399600, 189410400, 189421200,
+               189432000, 189442800, 189453600, 189464400, 189475200,
+               189486000])]
+        )
+
+        self.assertEqual(
+            testbin((1975, 12, 31, 0, 0, 0), (1976, 1, 1, 14, 30)),
+            [('1 day',
+              ['75 Dec 31', '76 Jan 01', '02'],
+              [189216000, 189302400, 189388800]),
+             ('12 hours',
+              ['75 Dec 31 00:00', '12:00',
+               '76 Jan 01 00:00', '12:00',
+               '02 00:00'],
+              [189216000, 189259200, 189302400, 189345600, 189388800]),
+             ('6 hours',
+              ['75 Dec 31 00:00', '06:00', '12:00', '18:00',
+               '76 Jan 01 00:00', '06:00', '12:00', '18:00'],
+              [189216000, 189237600, 189259200, 189280800, 189302400, 189324000,
+               189345600, 189367200]),
+             ('3 hours',
+              ['75 Dec 31 00:00', '03:00', '06:00', '09:00', '12:00', '15:00', '18:00', '21:00',
+               '76 Jan 01 00:00', '03:00', '06:00', '09:00', '12:00', '15:00'],
+              [189216000, 189226800, 189237600, 189248400, 189259200, 189270000,
+               189280800, 189291600, 189302400, 189313200, 189324000, 189334800,
+               189345600, 189356400]),
+             ('2 hours',
+              ['75 Dec 31 00:00', '02:00', '04:00', '06:00', '08:00', '10:00', '12:00', '14:00', '16:00', '18:00', '20:00', '22:00',
+               '76 Jan 01 00:00', '02:00', '04:00', '06:00', '08:00', '10:00', '12:00', '14:00', '16:00'],
+              [189216000, 189223200, 189230400, 189237600, 189244800, 189252000,
+               189259200, 189266400, 189273600, 189280800, 189288000, 189295200,
+               189302400, 189309600, 189316800, 189324000, 189331200, 189338400,
+               189345600, 189352800, 189360000]),
+             ('1 hour',
+              ['75 Dec 31 00:00', '01:00', '02:00', '03:00', '04:00', '05:00',
+               '06:00', '07:00', '08:00', '09:00', '10:00', '11:00', '12:00',
+               '13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00',
+               '20:00', '21:00', '22:00', '23:00',
+               '76 Jan 01 00:00', '01:00', '02:00', '03:00', '04:00', '05:00',
+               '06:00', '07:00', '08:00', '09:00', '10:00', '11:00', '12:00',
+               '13:00', '14:00', '15:00'],
+              [189216000, 189219600, 189223200, 189226800, 189230400, 189234000,
+               189237600, 189241200, 189244800, 189248400, 189252000, 189255600,
+               189259200, 189262800, 189266400, 189270000, 189273600, 189277200,
+               189280800, 189284400, 189288000, 189291600, 189295200, 189298800,
+               189302400, 189306000, 189309600, 189313200, 189316800, 189320400,
+               189324000, 189327600, 189331200, 189334800, 189338400, 189342000,
+               189345600, 189349200, 189352800, 189356400])]
+        )
+
+        self.assertEqual(
+            testbin((1975, 12, 31, 6), (1976, 1, 1)),
+            [('1 day',
+              ['75 Dec 31', '76 Jan 01', '02'],
+              [189216000, 189302400, 189388800]),
+             ('12 hours',
+              ['75 Dec 31 00:00', '12:00',
+               '76 Jan 01 00:00', '12:00'],
+              [189216000, 189259200, 189302400, 189345600]),
+             ('6 hours',
+              ['75 Dec 31 06:00', '12:00', '18:00',
+               '76 Jan 01 00:00', '06:00'],
+              [189237600, 189259200, 189280800, 189302400, 189324000]),
+             ('3 hours',
+              ['75 Dec 31 06:00', '09:00', '12:00', '15:00', '18:00', '21:00',
+               '76 Jan 01 00:00', '03:00'],
+              [189237600, 189248400, 189259200, 189270000, 189280800, 189291600,
+               189302400, 189313200]),
+             ('2 hours',
+              ['75 Dec 31 06:00', '08:00', '10:00', '12:00', '14:00', '16:00', '18:00', '20:00', '22:00',
+               '76 Jan 01 00:00', '02:00'],
+              [189237600, 189244800, 189252000, 189259200, 189266400, 189273600,
+               189280800, 189288000, 189295200, 189302400, 189309600]),
+             ('1 hour',
+              ['75 Dec 31 06:00', '07:00', '08:00', '09:00', '10:00', '11:00',
+               '12:00', '13:00', '14:00', '15:00', '16:00', '17:00', '18:00',
+               '19:00', '20:00', '21:00', '22:00', '23:00',
+               '76 Jan 01 00:00', '01:00'],
+              [189237600, 189241200, 189244800, 189248400, 189252000, 189255600,
+               189259200, 189262800, 189266400, 189270000, 189273600, 189277200,
+               189280800, 189284400, 189288000, 189291600, 189295200, 189298800,
+               189302400, 189306000]),
+             ('30 minutes',
+              ['Dec 31 06:00', '06:30', '07:00', '07:30', '08:00', '08:30',
+               '09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '12:00',
+               '12:30', '13:00', '13:30', '14:00', '14:30', '15:00', '15:30',
+               '16:00', '16:30', '17:00', '17:30', '18:00', '18:30', '19:00',
+               '19:30', '20:00', '20:30', '21:00', '21:30', '22:00', '22:30',
+               '23:00', '23:30',
+               'Jan 01 00:00', '00:30'],
+              [189237600, 189239400, 189241200, 189243000, 189244800, 189246600,
+               189248400, 189250200, 189252000, 189253800, 189255600, 189257400,
+               189259200, 189261000, 189262800, 189264600, 189266400, 189268200,
+               189270000, 189271800, 189273600, 189275400, 189277200, 189279000,
+               189280800, 189282600, 189284400, 189286200, 189288000, 189289800,
+               189291600, 189293400, 189295200, 189297000, 189298800, 189300600,
+               189302400, 189304200])]
+        )
+
+        self.assertEqual(
+            testbin((1975, 12, 31, 23), (1976, 1, 1, 2)),
+            [('3 hours',
+              ['75 Dec 31 21:00',
+               '76 Jan 01 00:00', '03:00'],
+              [189291600, 189302400, 189313200]),
+             ('2 hours',
+              ['75 Dec 31 22:00',
+               '76 Jan 01 00:00', '02:00', '04:00'],
+              [189295200, 189302400, 189309600, 189316800]),
+             ('1 hour',
+              ['75 Dec 31 23:00',
+               '76 Jan 01 00:00', '01:00', '02:00', '03:00'],
+              [189298800, 189302400, 189306000, 189309600, 189313200]),
+             ('30 minutes',
+              ['Dec 31 23:00', '23:30',
+               'Jan 01 00:00', '00:30', '01:00', '01:30', '02:00', '02:30'],
+              [189298800, 189300600, 189302400, 189304200, 189306000, 189307800,
+               189309600, 189311400]),
+             ('15 minutes',
+              ['Dec 31 23:00', '23:15', '23:30', '23:45',
+               'Jan 01 00:00', '00:15', '00:30', '00:45', '01:00', '01:15',
+               '01:30', '01:45', '02:00', '02:15'],
+              [189298800, 189299700, 189300600, 189301500, 189302400, 189303300,
+               189304200, 189305100, 189306000, 189306900, 189307800, 189308700,
+               189309600, 189310500]),
+             ('10 minutes',
+              ['Dec 31 23:00', '23:10', '23:20', '23:30', '23:40', '23:50',
+               'Jan 01 00:00', '00:10', '00:20', '00:30', '00:40', '00:50',
+               '01:00', '01:10', '01:20', '01:30', '01:40', '01:50', '02:00',
+               '02:10'],
+              [189298800, 189299400, 189300000, 189300600, 189301200, 189301800,
+               189302400, 189303000, 189303600, 189304200, 189304800, 189305400,
+               189306000, 189306600, 189307200, 189307800, 189308400, 189309000,
+               189309600, 189310200]),
+             ('5 minutes',
+              ['Dec 31 23:00', '23:05', '23:10', '23:15', '23:20', '23:25',
+               '23:30', '23:35', '23:40', '23:45', '23:50', '23:55',
+               'Jan 01 00:00', '00:05', '00:10', '00:15', '00:20', '00:25',
+               '00:30', '00:35', '00:40', '00:45', '00:50', '00:55', '01:00',
+               '01:05', '01:10', '01:15', '01:20', '01:25', '01:30', '01:35',
+               '01:40', '01:45', '01:50', '01:55', '02:00', '02:05'],
+              [189298800, 189299100, 189299400, 189299700, 189300000, 189300300,
+               189300600, 189300900, 189301200, 189301500, 189301800, 189302100,
+               189302400, 189302700, 189303000, 189303300, 189303600, 189303900,
+               189304200, 189304500, 189304800, 189305100, 189305400, 189305700,
+               189306000, 189306300, 189306600, 189306900, 189307200, 189307500,
+               189307800, 189308100, 189308400, 189308700, 189309000, 189309300,
+               189309600, 189309900])]
+        )
+
+        self.assertEqual(
+            testbin((1924, 6, 9, 10), (1924, 6, 9, 23, 18)),
+            [('12 hours',
+              ['Jun 09 00:00', '12:00', '10 00:00'],
+              [-1437868800, -1437825600, -1437782400]),
+             ('6 hours',
+              ['Jun 09 06:00', '12:00', '18:00', '10 00:00'],
+              [-1437847200, -1437825600, -1437804000, -1437782400]),
+             ('3 hours',
+              ['Jun 09 09:00', '12:00', '15:00', '18:00', '21:00', '10 00:00'],
+              [-1437836400, -1437825600, -1437814800, -1437804000, -1437793200,
+               -1437782400]),
+             ('2 hours',
+              ['Jun 09 10:00', '12:00', '14:00', '16:00', '18:00', '20:00', '22:00', '10 00:00'],
+              [-1437832800, -1437825600, -1437818400, -1437811200, -1437804000,
+               -1437796800, -1437789600, -1437782400]),
+             ('1 hour',
+              ['Jun 09 10:00', '11:00', '12:00', '13:00', '14:00', '15:00',
+               '16:00', '17:00', '18:00', '19:00', '20:00', '21:00', '22:00',
+               '23:00',
+               '10 00:00'],
+              [-1437832800, -1437829200, -1437825600, -1437822000, -1437818400,
+               -1437814800, -1437811200, -1437807600, -1437804000, -1437800400,
+               -1437796800, -1437793200, -1437789600, -1437786000,
+               -1437782400]),
+             ('30 minutes',
+              ['10:00', '10:30', '11:00', '11:30', '12:00', '12:30', '13:00',
+               '13:30', '14:00', '14:30', '15:00', '15:30', '16:00', '16:30',
+               '17:00', '17:30', '18:00', '18:30', '19:00', '19:30', '20:00',
+               '20:30', '21:00', '21:30', '22:00', '22:30', '23:00', '23:30'],
+              [-1437832800, -1437831000, -1437829200, -1437827400, -1437825600,
+               -1437823800, -1437822000, -1437820200, -1437818400, -1437816600,
+               -1437814800, -1437813000, -1437811200, -1437809400, -1437807600,
+               -1437805800, -1437804000, -1437802200, -1437800400, -1437798600,
+               -1437796800, -1437795000, -1437793200, -1437791400, -1437789600,
+               -1437787800, -1437786000, -1437784200])]
+        )
+
+        self.assertEqual(
+            testbin((1924, 6, 9, 10), (1924, 6, 9, 13, 18)),
+            [('2 hours',
+              ['10:00', '12:00', '14:00'],
+              [-1437832800, -1437825600, -1437818400]),
+             ('1 hour',
+              ['10:00', '11:00', '12:00', '13:00', '14:00'],
+              [-1437832800, -1437829200, -1437825600, -1437822000,
+               -1437818400]),
+             ('30 minutes',
+              ['10:00', '10:30', '11:00', '11:30', '12:00', '12:30', '13:00',
+               '13:30'],
+              [-1437832800, -1437831000, -1437829200, -1437827400, -1437825600,
+               -1437823800, -1437822000, -1437820200]),
+             ('15 minutes',
+              ['10:00', '10:15', '10:30', '10:45', '11:00', '11:15', '11:30',
+               '11:45', '12:00', '12:15', '12:30', '12:45', '13:00', '13:15',
+               '13:30'],
+              [-1437832800, -1437831900, -1437831000, -1437830100, -1437829200,
+               -1437828300, -1437827400, -1437826500, -1437825600, -1437824700,
+               -1437823800, -1437822900, -1437822000, -1437821100,
+               -1437820200]),
+             ('10 minutes',
+              ['10:00', '10:10', '10:20', '10:30', '10:40', '10:50', '11:00',
+               '11:10', '11:20', '11:30', '11:40', '11:50', '12:00', '12:10',
+               '12:20', '12:30', '12:40', '12:50', '13:00', '13:10', '13:20'],
+              [-1437832800, -1437832200, -1437831600, -1437831000, -1437830400,
+               -1437829800, -1437829200, -1437828600, -1437828000, -1437827400,
+               -1437826800, -1437826200, -1437825600, -1437825000, -1437824400,
+               -1437823800, -1437823200, -1437822600, -1437822000, -1437821400,
+               -1437820800]),
+             ('5 minutes',
+              ['10:00', '10:05', '10:10', '10:15', '10:20', '10:25', '10:30',
+               '10:35', '10:40', '10:45', '10:50', '10:55', '11:00', '11:05',
+               '11:10', '11:15', '11:20', '11:25', '11:30', '11:35', '11:40',
+               '11:45', '11:50', '11:55', '12:00', '12:05', '12:10', '12:15',
+               '12:20', '12:25', '12:30', '12:35', '12:40', '12:45', '12:50',
+               '12:55', '13:00', '13:05', '13:10', '13:15', '13:20'],
+              [-1437832800, -1437832500, -1437832200, -1437831900, -1437831600,
+               -1437831300, -1437831000, -1437830700, -1437830400, -1437830100,
+               -1437829800, -1437829500, -1437829200, -1437828900, -1437828600,
+               -1437828300, -1437828000, -1437827700, -1437827400, -1437827100,
+               -1437826800, -1437826500, -1437826200, -1437825900, -1437825600,
+               -1437825300, -1437825000, -1437824700, -1437824400, -1437824100,
+               -1437823800, -1437823500, -1437823200, -1437822900, -1437822600,
+               -1437822300, -1437822000, -1437821700, -1437821400, -1437821100,
+               -1437820800])]
+        )
+
+        self.assertEqual(
+            testbin((1924, 6, 9, 10), (1924, 6, 9, 10, 48)),
+            [('30 minutes',
+              ['10:00', '10:30', '11:00'],
+              [-1437832800, -1437831000, -1437829200]),
+             ('15 minutes',
+              ['10:00', '10:15', '10:30', '10:45', '11:00'],
+              [-1437832800, -1437831900, -1437831000, -1437830100,
+               -1437829200]),
+             ('10 minutes',
+              ['10:00', '10:10', '10:20', '10:30', '10:40', '10:50'],
+              [-1437832800, -1437832200, -1437831600, -1437831000, -1437830400,
+               -1437829800]),
+             ('5 minutes',
+              ['10:00', '10:05', '10:10', '10:15', '10:20', '10:25', '10:30',
+               '10:35', '10:40', '10:45', '10:50'],
+              [-1437832800, -1437832500, -1437832200, -1437831900, -1437831600,
+               -1437831300, -1437831000, -1437830700, -1437830400, -1437830100,
+               -1437829800]),
+             ('1 minute',
+              ['10:00', '10:01', '10:02', '10:03', '10:04', '10:05', '10:06',
+               '10:07', '10:08', '10:09', '10:10', '10:11', '10:12', '10:13',
+               '10:14', '10:15', '10:16', '10:17', '10:18', '10:19', '10:20',
+               '10:21', '10:22', '10:23', '10:24', '10:25', '10:26', '10:27',
+               '10:28', '10:29', '10:30', '10:31', '10:32', '10:33', '10:34',
+               '10:35', '10:36', '10:37', '10:38', '10:39', '10:40', '10:41',
+               '10:42', '10:43', '10:44', '10:45', '10:46', '10:47', '10:48',
+               '10:49'],
+              [-1437832800, -1437832740, -1437832680, -1437832620, -1437832560,
+               -1437832500, -1437832440, -1437832380, -1437832320, -1437832260,
+               -1437832200, -1437832140, -1437832080, -1437832020, -1437831960,
+               -1437831900, -1437831840, -1437831780, -1437831720, -1437831660,
+               -1437831600, -1437831540, -1437831480, -1437831420, -1437831360,
+               -1437831300, -1437831240, -1437831180, -1437831120, -1437831060,
+               -1437831000, -1437830940, -1437830880, -1437830820, -1437830760,
+               -1437830700, -1437830640, -1437830580, -1437830520, -1437830460,
+               -1437830400, -1437830340, -1437830280, -1437830220, -1437830160,
+               -1437830100, -1437830040, -1437829980, -1437829920, -1437829860]
+              )]
+        )
+
+        self.assertEqual(
+            testbin((1924, 6, 9, 10), (1924, 6, 9, 10, 20)),
+            [('15 minutes',
+              ['10:00', '10:15', '10:30'],
+              [-1437832800, -1437831900, -1437831000]),
+             ('10 minutes',
+              ['10:00', '10:10', '10:20', '10:30'],
+              [-1437832800, -1437832200, -1437831600, -1437831000]),
+             ('5 minutes',
+              ['10:00', '10:05', '10:10', '10:15', '10:20', '10:25'],
+              [-1437832800, -1437832500, -1437832200, -1437831900, -1437831600,
+               -1437831300]),
+             ('1 minute',
+              ['10:00', '10:01', '10:02', '10:03', '10:04', '10:05', '10:06',
+               '10:07', '10:08', '10:09', '10:10', '10:11', '10:12', '10:13',
+               '10:14', '10:15', '10:16', '10:17', '10:18', '10:19', '10:20',
+               '10:21'],
+              [-1437832800, -1437832740, -1437832680, -1437832620, -1437832560,
+               -1437832500, -1437832440, -1437832380, -1437832320, -1437832260,
+               -1437832200, -1437832140, -1437832080, -1437832020, -1437831960,
+               -1437831900, -1437831840, -1437831780, -1437831720, -1437831660,
+               -1437831600, -1437831540]),
+             ('30 seconds',
+              ['10:00:00', '10:00:30', '10:01:00', '10:01:30', '10:02:00',
+               '10:02:30', '10:03:00', '10:03:30', '10:04:00', '10:04:30',
+               '10:05:00', '10:05:30', '10:06:00', '10:06:30', '10:07:00',
+               '10:07:30', '10:08:00', '10:08:30', '10:09:00', '10:09:30',
+               '10:10:00', '10:10:30', '10:11:00', '10:11:30', '10:12:00',
+               '10:12:30', '10:13:00', '10:13:30', '10:14:00', '10:14:30',
+               '10:15:00', '10:15:30', '10:16:00', '10:16:30', '10:17:00',
+               '10:17:30', '10:18:00', '10:18:30', '10:19:00', '10:19:30',
+               '10:20:00', '10:20:30'],
+              [-1437832800, -1437832770, -1437832740, -1437832710, -1437832680,
+               -1437832650, -1437832620, -1437832590, -1437832560, -1437832530,
+               -1437832500, -1437832470, -1437832440, -1437832410, -1437832380,
+               -1437832350, -1437832320, -1437832290, -1437832260, -1437832230,
+               -1437832200, -1437832170, -1437832140, -1437832110, -1437832080,
+               -1437832050, -1437832020, -1437831990, -1437831960, -1437831930,
+               -1437831900, -1437831870, -1437831840, -1437831810, -1437831780,
+               -1437831750, -1437831720, -1437831690, -1437831660, -1437831630,
+               -1437831600, -1437831570])])
+
+        self.assertEqual(
+            testbin((1924, 6, 9, 10, 12, 33), (1924, 6, 9, 10, 18, 12)),
+            [('5 minutes',
+              ['10:10', '10:15', '10:20'],
+              [-1437832200, -1437831900, -1437831600]),
+             ('1 minute',
+              ['10:12', '10:13', '10:14', '10:15', '10:16', '10:17', '10:18',
+               '10:19'],
+              [-1437832080, -1437832020, -1437831960, -1437831900, -1437831840,
+               -1437831780, -1437831720, -1437831660]),
+             ('30 seconds',
+              ['10:12:30', '10:13:00', '10:13:30', '10:14:00', '10:14:30',
+               '10:15:00', '10:15:30', '10:16:00', '10:16:30', '10:17:00',
+               '10:17:30', '10:18:00', '10:18:30'],
+              [-1437832050, -1437832020, -1437831990, -1437831960, -1437831930,
+               -1437831900, -1437831870, -1437831840, -1437831810, -1437831780,
+               -1437831750, -1437831720, -1437831690]),
+             ('15 seconds',
+              ['10:12:30', '10:12:45', '10:13:00', '10:13:15', '10:13:30',
+               '10:13:45', '10:14:00', '10:14:15', '10:14:30', '10:14:45',
+               '10:15:00', '10:15:15', '10:15:30', '10:15:45', '10:16:00',
+               '10:16:15', '10:16:30', '10:16:45', '10:17:00', '10:17:15',
+               '10:17:30', '10:17:45', '10:18:00', '10:18:15'],
+              [-1437832050, -1437832035, -1437832020, -1437832005, -1437831990,
+               -1437831975, -1437831960, -1437831945, -1437831930, -1437831915,
+               -1437831900, -1437831885, -1437831870, -1437831855, -1437831840,
+               -1437831825, -1437831810, -1437831795, -1437831780, -1437831765,
+               -1437831750, -1437831735, -1437831720, -1437831705]),
+             ('10 seconds',
+              ['10:12:30', '10:12:40', '10:12:50', '10:13:00', '10:13:10',
+               '10:13:20', '10:13:30', '10:13:40', '10:13:50', '10:14:00',
+               '10:14:10', '10:14:20', '10:14:30', '10:14:40', '10:14:50',
+               '10:15:00', '10:15:10', '10:15:20', '10:15:30', '10:15:40',
+               '10:15:50', '10:16:00', '10:16:10', '10:16:20', '10:16:30',
+               '10:16:40', '10:16:50', '10:17:00', '10:17:10', '10:17:20',
+               '10:17:30', '10:17:40', '10:17:50', '10:18:00', '10:18:10',
+               '10:18:20'],
+              [-1437832050, -1437832040, -1437832030, -1437832020, -1437832010,
+               -1437832000, -1437831990, -1437831980, -1437831970, -1437831960,
+               -1437831950, -1437831940, -1437831930, -1437831920, -1437831910,
+               -1437831900, -1437831890, -1437831880, -1437831870, -1437831860,
+               -1437831850, -1437831840, -1437831830, -1437831820, -1437831810,
+               -1437831800, -1437831790, -1437831780, -1437831770, -1437831760,
+               -1437831750, -1437831740, -1437831730, -1437831720, -1437831710,
+               -1437831700])])
+
+        self.assertEqual(
+            testbin((1924, 6, 9, 10, 12, 33), (1924, 6, 9, 10, 13, 12)),
+            [('30 seconds',
+              ['10:12:30', '10:13:00', '10:13:30'],
+              [-1437832050, -1437832020, -1437831990]),
+             ('15 seconds',
+              ['10:12:30', '10:12:45', '10:13:00', '10:13:15'],
+              [-1437832050, -1437832035, -1437832020, -1437832005]),
+             ('10 seconds',
+              ['10:12:30', '10:12:40', '10:12:50', '10:13:00', '10:13:10',
+               '10:13:20'],
+              [-1437832050, -1437832040, -1437832030, -1437832020, -1437832010,
+               -1437832000]),
+             ('5 seconds',
+              ['10:12:30', '10:12:35', '10:12:40', '10:12:45', '10:12:50',
+               '10:12:55', '10:13:00', '10:13:05', '10:13:10', '10:13:15'],
+              [-1437832050, -1437832045, -1437832040, -1437832035, -1437832030,
+               -1437832025, -1437832020, -1437832015, -1437832010,
+               -1437832005]),
+             ('1 second',
+              ['10:12:33', '10:12:34', '10:12:35', '10:12:36', '10:12:37',
+               '10:12:38', '10:12:39', '10:12:40', '10:12:41', '10:12:42',
+               '10:12:43', '10:12:44', '10:12:45', '10:12:46', '10:12:47',
+               '10:12:48', '10:12:49', '10:12:50', '10:12:51', '10:12:52',
+               '10:12:53', '10:12:54', '10:12:55', '10:12:56', '10:12:57',
+               '10:12:58', '10:12:59', '10:13:00', '10:13:01', '10:13:02',
+               '10:13:03', '10:13:04', '10:13:05', '10:13:06', '10:13:07',
+               '10:13:08', '10:13:09', '10:13:10', '10:13:11', '10:13:12',
+               '10:13:13'],
+              [-1437832047, -1437832046, -1437832045, -1437832044, -1437832043,
+               -1437832042, -1437832041, -1437832040, -1437832039, -1437832038,
+               -1437832037, -1437832036, -1437832035, -1437832034, -1437832033,
+               -1437832032, -1437832031, -1437832030, -1437832029, -1437832028,
+               -1437832027, -1437832026, -1437832025, -1437832024, -1437832023,
+               -1437832022, -1437832021, -1437832020, -1437832019, -1437832018,
+               -1437832017, -1437832016, -1437832015, -1437832014, -1437832013,
+               -1437832012, -1437832011, -1437832010, -1437832009, -1437832008,
+               -1437832007])])
+
+    def test_min_unique(self):
+        bins = time_binnings(self.dates, min_unique=7)
+        self.assertEqual(len(bins), 1)
+        np.testing.assert_equal(bins[0].thresholds[:-1], self.dates)
+
+    def test_add_unique(self):
+        bins = time_binnings(self.dates, add_unique=7)
+        self.assertNotEqual(len(bins), 1)
+        np.testing.assert_equal(bins[0].thresholds[:-1], self.dates)
+
+    def test_limits(self):
+        self.assertEqual(
+            {b.nbins
+             for b in time_binnings(self.dates, min_bins=9, max_bins=17)},
+            {9, 17})
+        self.assertEqual(
+            {b.nbins
+             for b in time_binnings(self.dates, min_bins=9, max_bins=16)},
+            {9})
+        self.assertEqual(
+            {b.nbins
+             for b in time_binnings(self.dates, min_bins=10, max_bins=17)},
+            {17})
+
+    def test_single_value(self):
+        dates = np.array([42])
+        bins = time_binnings(dates)
+        self.assertEqual(len(bins), 1)
+        np.testing.assert_equal(bins[0].thresholds, [42, 43])
+
+    def test_no_values(self):
+        dates = np.array([])
+        self.assertRaises(ValueError, time_binnings, dates)
+
+        dates = np.array([np.nan, np.nan])
+        self.assertRaises(ValueError, time_binnings, dates)
+
+
+class TestBinDefinition(unittest.TestCase):
+    def test_labels(self):
+        thresholds = np.array([1, 2, 3.14])
+        self.assertEqual(BinDefinition(thresholds).labels,
+                         ["1", "2", "3.14"])
+        self.assertEqual(BinDefinition(thresholds, "%.3f").labels,
+                         ["1.000", "2.000", "3.140"])
+        self.assertEqual(BinDefinition(thresholds, lambda x: f"b{x:g}").labels,
+                         ["b1", "b2", "b3.14"])
+        self.assertEqual(BinDefinition(thresholds, list("abc")).labels,
+                         list("abc"))
+
+    def test_width_label(self):
+        thresholds = np.array([1, 2, 3.14])
+        self.assertEqual(BinDefinition(thresholds).width_label, "")
+        self.assertEqual(BinDefinition(thresholds, width=3).width_label, "3")
+        self.assertEqual(BinDefinition(thresholds, width=3.14).width_label, "3.14")
+
+    def test_thresholds(self):
+        thresholds = np.array([1, 2, 3.14])
+        bindef = BinDefinition(thresholds)
+        np.testing.assert_equal(bindef.thresholds, thresholds)
+        self.assertEqual(bindef.start, 1)
+        self.assertEqual(bindef.nbins, 2)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/Orange/widgets/visualize/tests/test_owdistributions.py b/Orange/widgets/visualize/tests/test_owdistributions.py
--- a/Orange/widgets/visualize/tests/test_owdistributions.py
+++ b/Orange/widgets/visualize/tests/test_owdistributions.py
@@ -193,8 +193,10 @@ def test_switch_var(self):
         widget.apply = Mock()
 
         self._set_var(2)
-        self.assertFalse(binnings[0].shape == widget.binnings[0].shape
-                         and np.allclose(binnings[0], widget.binnings[0]))
+        self.assertFalse(
+            binnings[0].thresholds.shape == widget.binnings[0].thresholds.shape
+            and np.allclose(binnings[0].threshold, widget.binnings[0].threshold)
+        )
         self.assertFalse(valid_data.shape == widget.valid_data.shape
                          and np.allclose(valid_data, widget.valid_data))
         self.assertEqual(widget.selection, set())