Exemplo n.º 1
0
def test_copyDataFrame(copy, operator):
    dataFrame = pandas.DataFrame([0], columns=["A"])
    model = DataFrameModel(dataFrame, copyDataFrame=copy)
    assert operator(id(model.dataFrame()), id(dataFrame))

    model.setDataFrame(dataFrame, copyDataFrame=copy)
    assert operator(id(model.dataFrame()), id(dataFrame))
Exemplo n.º 2
0
    def setDataFrame(self, dataFrame):
        self.df = dataFrame
        dataModel = DataFrameModel()
        dataModel.setDataFrame(self.df)
        self.dataListView.setModel(dataModel)
        self.dataTableView.setViewModel(dataModel)
        self.dataComboBox.setModel(dataModel)

        for index, column in enumerate(dataModel.dataFrame().columns):
            dtype = dataModel.dataFrame()[column].dtype
            self.updateDelegates(index, dtype)
        #self.updateDelegates()

        # self.dataTableView.resizeColumnsToContents()

        # create a simple item model for our choosing combobox
        columnModel = QtGui.QStandardItemModel()
        for column in self.df.columns:
            columnModel.appendRow(QtGui.QStandardItem(column))
        self.chooseColumnComboBox.setModel(columnModel)

        self.tableViewColumnDtypes.setModel(dataModel.columnDtypeModel())
        self.tableViewColumnDtypes.horizontalHeader().setDefaultSectionSize(200)
        self.tableViewColumnDtypes.setItemDelegateForColumn(1, DtypeComboDelegate(self.tableViewColumnDtypes))
        dataModel.dtypeChanged.connect(self.updateDelegates)
        dataModel.changingDtypeFailed.connect(self.changeColumnValue)
Exemplo n.º 3
0
def test_copyDataFrame(copy, operator):
    dataFrame = pandas.DataFrame([0], columns=['A'])
    model = DataFrameModel(dataFrame, copyDataFrame=copy)
    assert operator(id(model.dataFrame()), id(dataFrame))

    model.setDataFrame(dataFrame, copyDataFrame=copy)
    assert operator(id(model.dataFrame()), id(dataFrame))
Exemplo n.º 4
0
    def setDataFrame(self, dataFrame):
        self.df = dataFrame
        dataModel = DataFrameModel()
        dataModel.setDataFrame(self.df)
        self.dataListView.setModel(dataModel)
        self.dataTableView.setViewModel(dataModel)
        self.dataComboBox.setModel(dataModel)

        for index, column in enumerate(dataModel.dataFrame().columns):
            dtype = dataModel.dataFrame()[column].dtype
            self.updateDelegates(index, dtype)
        #self.updateDelegates()

        # self.dataTableView.resizeColumnsToContents()

        # create a simple item model for our choosing combobox
        columnModel = QtGui.QStandardItemModel()
        for column in self.df.columns:
            columnModel.appendRow(QtGui.QStandardItem(column))
        self.chooseColumnComboBox.setModel(columnModel)

        self.tableViewColumnDtypes.setModel(dataModel.columnDtypeModel())
        self.tableViewColumnDtypes.horizontalHeader().setDefaultSectionSize(
            200)
        self.tableViewColumnDtypes.setItemDelegateForColumn(
            1, DtypeComboDelegate(self.tableViewColumnDtypes))
        dataModel.dtypeChanged.connect(self.updateDelegates)
        dataModel.changingDtypeFailed.connect(self.changeColumnValue)
Exemplo n.º 5
0
def test_setDataFrame():
    dataFrame = pandas.DataFrame([0], columns=["A"])
    model = DataFrameModel()
    model.setDataFrame(dataFrame)
    assert not model.dataFrame().empty
    assert model.dataFrame() is dataFrame

    with pytest.raises(TypeError) as excinfo:
        model.setDataFrame(None)
    assert "pandas.core.frame.DataFrame" in unicode(excinfo.value)
Exemplo n.º 6
0
def test_setDataFrame():
    dataFrame = pandas.DataFrame([0], columns=['A'])
    model = DataFrameModel()
    model.setDataFrame(dataFrame)
    assert not model.dataFrame().empty
    assert model.dataFrame() is dataFrame

    with pytest.raises(TypeError) as excinfo:
        model.setDataFrame(None)
    assert "pandas.core.frame.DataFrame" in unicode(excinfo.value)
Exemplo n.º 7
0
    def test_remove_columns_random(self, dataFrame):

        columnNames = dataFrame.columns.tolist()
        columnNames = [(i, n) for i, n in enumerate(columnNames)]

        for cycle in xrange(1000):
            elements = random.randint(1, len(columnNames))
            names = random.sample(columnNames, elements)
            df = dataFrame.copy()
            model = DataFrameModel(df)
            assert not model.removeDataFrameColumns(names)
            model.enableEditing(True)
            model.removeDataFrameColumns(names)

            _columnSet = set(columnNames)
            _removedSet = set(names)
            remainingColumns = _columnSet - _removedSet
            for idx, col in remainingColumns:
                assert col in model.dataFrame().columns.tolist()
Exemplo n.º 8
0
    def test_remove_columns_random(self, dataFrame):

        columnNames = dataFrame.columns.tolist()
        columnNames = [(i, n) for i, n in enumerate(columnNames)]

        for cycle in xrange(1000):
            elements = random.randint(1, len(columnNames))
            names = random.sample(columnNames, elements)
            df = dataFrame.copy()
            model = DataFrameModel(df)
            assert not model.removeDataFrameColumns(names)
            model.enableEditing(True)
            model.removeDataFrameColumns(names)

            _columnSet = set(columnNames)
            _removedSet = set(names)
            remainingColumns = _columnSet - _removedSet
            for idx, col in remainingColumns:
                assert col in model.dataFrame().columns.tolist()
Exemplo n.º 9
0
def test_initDataFrameWithDataFrame():
    dataFrame = pandas.DataFrame([0], columns=["A"])
    model = DataFrameModel(dataFrame)
    assert not model.dataFrame().empty
    assert model.dataFrame() is dataFrame
Exemplo n.º 10
0
def test_initDataFrame():
    model = DataFrameModel()
    assert model.dataFrame().empty
Exemplo n.º 11
0
def test_initDataFrameWithDataFrame():
    dataFrame = pandas.DataFrame([0], columns=['A'])
    model = DataFrameModel(dataFrame)
    assert not model.dataFrame().empty
    assert model.dataFrame() is dataFrame
Exemplo n.º 12
0
def test_initDataFrame():
    model = DataFrameModel()
    assert model.dataFrame().empty
Exemplo n.º 13
0
class TracksterGUI(Ui_MainWindow):
    # -----
    # Setup
    # -----

    # PyQt signals to be emitted when the underlying database is change
    # Lets components that hold views on that data to update themselves
    wl_data_changed = Signal()  # Workout log changed
    bw_data_changed = Signal()  # Body weight log changed
    lt_data_changed = Signal()  # Lift types database changed

    build_data_changed = Signal()  # The workout being built in the Build tab was changed
    log_data_changed = Signal()
    analysis_data_changed = Signal()

    def __init__(self):
        # super().__init__()
        self.db = Backend(filename)
        self._wl_model = DataFrameModel(self.db.workout_log, copyDataFrame=False, show_index=True)
        self._bw_model = DataFrameModel(self.db.weight_log, copyDataFrame=False, show_index=True)
        self._lt_model = DataFrameModel(self.db.lift_types, copyDataFrame=False, show_index=False)

    def setupUi(self, main_window):
        super().setupUi(main_window)
        self.setup_global()
        self.__setup_dashboard_tab()
        self.__setup_body_weight_tab()
        self.__setup_log_tab()
        self.__setup_build_tab()
        self.__setup_analysis_tab()
        self.__connect_update_slots_and_signals()

    def setup_global(self):
        self._wl_model.timestampFormat = "yyyy-MM-dd"
        self._bw_model.timestampFormat = "yyyy-MM-dd"
        self._lt_model.timestampFormat = "yyyy-MM-dd"

        self.actionSave.triggered.connect(self.save_logs)
        self.actionRevert.triggered.connect(self.revert_from_temp)

        self.tabs.tabBarClicked.connect(self.switching_main_tabs_event)

    def __setup_dashboard_tab(self):
        pass

    def __setup_body_weight_tab(self):
        self.bw_table.setModel(self._bw_model)
        self._bw_model.enableEditing(True)
        self._bw_model.set_cell_colors(Theme.bw_color_cols, Theme.red_brush, Theme.green_brush)

        self.bw_table.tableView.setSortingEnabled(False)
        self.bw_table.tableView.scrollToBottom()
        self.bw_table.enableEditing(True)

        self.bw_date.setDate(QDate.currentDate())

        self.bw_plot_data()
        self.bw_update_stats()

        self.bw_add.clicked.connect(self.bw_add_entry)
        self.bw_save.clicked.connect(self.db.save_bw_temp)
        self.bw_delete.clicked.connect(self.bw_delete_row)

    def __setup_log_tab(self):
        self._log_table_view = pd.DataFrame()
        self._log_table_model = DataFrameModel(self._log_table_view, copyDataFrame=False, show_index=False)
        self._log_table_model.enableEditing(True)
        self._log_table_model.dataChanged.connect(self.log_values_changed)
        self.log_table.setModel(self._log_table_model)
        self.log_table.horizontalHeader().setStretchLastSection(True)

        self._log_date = pd.Timestamp.today()
        self._log_lift = ''
        self._log_unsynced_changes = False
        self._log_unsaved_changes = False

        self.log_date.setDate(QDate.currentDate())
        self.log_cal.setSelectedDate(QDate.currentDate())

        self.log_date_changed(pd.Timestamp.today())
        self.log_populate_lifts_box()
        self.log_populate_table()

        self.log_date.dateChanged.connect(self.log_date_selection_changed)
        self.log_cal.selectionChanged.connect(self.log_cal_selection_changed)
        self.log_add.clicked.connect(self.log_add_entry)
        self.log_save.clicked.connect(self.log_sync_changes)
        self.log_delete.clicked.connect(self.log_del_row)

    def __setup_build_tab(self):
        self._build_cur_workout = pd.DataFrame()
        self._build_cur_workout_view = pd.DataFrame()
        self._build_cur_lift = None
        self._build_tgt_e1rm = -1
        self._build_tabs = OrderedDict()
        self._build_unsaved_changes = False

        self._build_cur_model = DataFrameModel(self._build_cur_workout, copyDataFrame=False, show_index=False)
        self._build_pre_model = DataFrameModel(dataFrame=None, copyDataFrame=False, show_index=False)
        self._build_cur_model_view = DataFrameModel(dataFrame=None, copyDataFrame=False, show_index=False)

        self.build_pre_workout.setModel(self._build_pre_model)

        self._build_rpe_model = RpeTableModel(self.db)
        self.build_RPE.setModel(self._build_rpe_model)

        self._build_stats_model = StatsTableModel(self.db)
        self.build_stats.setModel(self._build_stats_model)

        self.build_date.setDate(QtCore.QDate.currentDate())
        self.build_tree_populate()

        self.build_search.textChanged.connect(self.build_filter)
        self.build_lifts.itemDoubleClicked.connect(self.build_lift_selected)
        self.build_previous_slider.valueChanged.connect(self.build_populate_previous_workout_table)
        self.build_target.valueChanged.connect(self.build_populate_rpe_table)
        self.build_btn_add.clicked.connect(self.build_add_btn_clicked)
        self.build_RPE.clicked.connect(self.build_top_set_selected)
        self.build_tabs.currentChanged.connect(self.build_lift_tab_changed)
        self.build_accept.clicked.connect(self.build_accept_workout)
        self.build_clear.clicked.connect(self.build_clear_workout)
        self.build_btn_delete.clicked.connect(self.build_delete_row)

    def __setup_analysis_tab(self):
        self.an_data = {}
        self._an_cur_analysis = None
        self.an_type.setCurrentIndex(0)
        self.an_update_values_box()

        self.an_type.currentTextChanged.connect(self.an_update_values_box)
        self.an_plots.itemSelectionChanged.connect(self.an_analysis_selected)
        self.an_add.clicked.connect(self.an_add_analysis)
        self.an_remove.clicked.connect(self.an_remove_analysis)

    def __connect_update_slots_and_signals(self):
        self.bw_data_changed.connect(self._bw_model.layoutChanged.emit)
        self.bw_data_changed.connect(self.bw_plot_data)
        self.bw_data_changed.connect(self.bw_update_stats)

        self._bw_model.dataFrameChanged.connect(self.bw_data_changed.emit)
        self._bw_model.dataChanged.connect(self.bw_data_changed.emit)

        self.wl_data_changed.connect(self.log_data_changed.emit)

        self.lt_data_changed.connect(self.an_update_values_box)
        self.lt_data_changed.connect(self.build_tree_populate)
        self.lt_data_changed.connect(self.log_populate_lifts_box)

        self.build_data_changed.connect(self.build_update_previous_workout_stats)
        self.build_data_changed.connect(self.build_update_current_workout_stats)
        self.build_data_changed.connect(self.build_populate_previous_workout_table)
        self.build_data_changed.connect(self.build_gen_workout_tables)

        self.log_data_changed.connect(self.log_populate_table)
        self.log_data_changed.connect(self.log_populate_stats)
        self.log_data_changed.connect(partial(self.build_stats.model().update, db_modified=True))

        self.analysis_data_changed.connect(self.an_plot_analysis)
        self.analysis_data_changed.connect(self.an_update_values_box)
        self.analysis_data_changed.connect(self.an_populate_plots_list)

    # -----
    # Body Weight Tab
    # -----

    def bw_add_entry(self):
        high = None
        low = None
        bf = None
        try:
            date = pd.Timestamp(self.bw_date.text())
            if self.bw_high.text() != '':
                high = float(self.bw_high.text())
            if self.bw_low.text() != '':
                low = float(self.bw_low.text())
            if self.bw_bf.text() != '':
                bf = float(self.bw_bf.text()) / 100.0
            comments = self.bw_comments.text()
            self.db.add_bodyweight_entry(date, high, low, bf, comments)
        except (ValueError, TypeError):
            pass
        self.bw_data_changed.emit()

    @pyqtSlot()
    def bw_plot_data(self):
        # TODO: Make prettier
        self.bw_plot.clear()

        above_goal_data = np.maximum(self.db.weight_log.High, self.db.weight_log.Goal)
        below_goal_data = np.minimum(self.db.weight_log.High, self.db.weight_log.Goal)

        self.bw_plot.plot_ts(self.db.weight_log.Goal)
        self.bw_plot.plot_ts(above_goal_data)
        self.bw_plot.plot_ts(below_goal_data)
        self.bw_plot.plot_ts(self.db.weight_log.Low)

        above_goal_fill = pg.FillBetweenItem(self.bw_plot.plotItem.curves[0], self.bw_plot.plotItem.curves[1], 'r')
        below_goal_fill = pg.FillBetweenItem(self.bw_plot.plotItem.curves[0], self.bw_plot.plotItem.curves[2], 'g')

        self.bw_plot.getPlotItem().addItem(above_goal_fill)
        self.bw_plot.getPlotItem().addItem(below_goal_fill)

        ma_data = pd.rolling_mean(self.db.weight_log.High, 7, min_periods=1)
        self.bw_plot.plot_ts(ma_data)

    @pyqtSlot()
    def bw_update_stats(self):
        daily_diff = self.db.weight_log.Diff[self.db.weight_log.Diff.notnull()].iloc[-1]
        daily_diff = round(daily_diff, 1)
        self.bw_daily_diff.setText(str(daily_diff))

        goal_diff = self.db.weight_log.Goal_Diff[self.db.weight_log.Goal_Diff.notnull()].iloc[-1]
        goal_diff = round(goal_diff, 1)
        self.bw_goal_diff.setText(str(goal_diff))

        weekly_diff = self.db.weight_log.High.iloc[-7:].mean() - self.db.weight_log.High.iloc[-14:-7].mean()
        weekly_diff = round(weekly_diff, 1)
        self.bw_avg_week.setText(str(weekly_diff))

    @pyqtSlot()
    def bw_delete_row(self):
        # self.bw_table.removeRow(triggered=True)
        # self.bw_data_changed.emit()
        pass

    # -----
    # Analysis Tab
    # -----

    def an_add_analysis(self):
        data = self.db.workout_log
        group = self.an_type.currentText()
        val = self.an_value.currentText()
        try:
            if group == 'Slot':
                data = data[data.Slot == val]
            elif group == 'Category':
                data = data[data.Category == val]
            elif group == 'Lift':
                data = data[data.Lift == val]
            else:
                return
        except (ValueError, TypeError) as err:
            log_err(err, "Invalid analysis type selected: {}, {}".format(group, val))

        analysis_funcs = {'E1RMs': analysis.get_max_e1RM, 'Volume': analysis.get_volume,
                          'Norm V': analysis.calc_normalized_tonnage, 'Fatigue': analysis.get_fatigue}
        selected_func = analysis_funcs[self.an_analysis.currentText()]
        analyzed_data = selected_func(data)
        title = '{}: {} -> {}'.format(self.an_analysis.currentText(), group, val)

        if self.an_mov_avg.isChecked():
            period = self.an_period.value()
            if 'e1rm' in selected_func.__name__:
                analyzed_data = analyzed_data.resample('1D', label='right', how=np.max)
            elif 'vol' in selected_func.__name__ or 'fatigue' in selected_func.__name__:
                analyzed_data = analyzed_data.resample('1D', label='right', how=np.sum)
                analyzed_data = analyzed_data.fillna(0)

            analyzed_data = pd.rolling_mean(analyzed_data, period, min_periods=1)

            if 'fatigue' in selected_func.__name__:
                analyzed_data *= 7.0
            title += ' ({} day avg)'.format(period)

        self.an_data[title] = analyzed_data
        self.analysis_data_changed.emit()

    @pyqtSlot()
    def an_populate_plots_list(self):
        self.an_plots.clear()
        titles = [x for x in self.an_data.keys()]
        self.an_plots.addItems(titles)

    @pyqtSlot()
    def an_analysis_selected(self, which=None):
        if not which or not isinstance(which, str):
            which = self.an_plots.currentItem().text()
        self._an_cur_analysis = which
        self.an_remove.setEnabled(True)

    @pyqtSlot()
    def an_plot_analysis(self):
        self.an_plot.plot_with_shared_axes(self.an_data)

    @pyqtSlot()
    def an_remove_analysis(self, which):
        if which:
            if isinstance(which, QTreeWidgetItem):
                which = which.text(0)
            elif isinstance(which, str):
                pass
            else:
                return
        elif pd.notnull(self._an_cur_analysis):
            which = self._an_cur_analysis

        if which in self.an_data:
            self.an_data.pop(which)
            self.analysis_data_changed.emit()

        if len(self.an_data) > 1:
            self.an_remove.setEnabled(False)

    @pyqtSlot(str, str)
    def an_update_values_box(self):
        self.an_value.clear()
        type = self.an_type.currentText()
        vals = self.db.lift_types[type].unique()
        self.an_value.addItems(vals)

    # -----
    # Log Tab
    # -----

    @pyqtSlot()
    def log_populate_table(self, date=None, lift=None):
        if lift and date and isinstance(date, pd.Timestamp) and isinstance(lift, str):
            if lift == self._log_cur_lift and date == self._log_date:
                return
            else:
                self._log_cur_lift = lift
                self._log_date = date
        self._log_table_view = self.db.workout_log[self.db.workout_log.index == self._log_date].reset_index()
        self._log_table_view = self._log_table_view[['Slot', 'Category', 'Lift', 'Weight', 'Reps', 'RPE', 'Comments']]
        self._log_table_model.setDataFrame(self._log_table_view)
        self._log_table_model.dataChanged.emit()
        self._log_table_model.layoutChanged.emit()

    @pyqtSlot()
    def log_populate_stats(self, lift=None):
        if pd.isnull(self.log_date) or self._log_table_view.empty:
            return
        self.log_tree.clear()

        cur_wkt = self.db.workout_log[self.db.workout_log.index == self._log_date]

        pre_30d = self.db.workout_log[self.db.workout_log.index >= self._log_date - pd.Timedelta(days=30)]
        pre_30d = pre_30d[pre_30d.index < self._log_date]

        e1RM_node = QTreeWidgetItem()
        e1RM_node.setText(0, 'e1RMs:')
        for lift, lift_data in cur_wkt.groupby('Lift'):
            e1RM = max(analysis.get_max_e1RM(lift_data))
            if pre_30d.Lift.isin([lift]).any():
                e1RM_pre_30d = max(analysis.get_max_e1RM(pre_30d[pre_30d.Lift == lift]))
            else:
                e1RM_pre_30d = ''

            child_node = QTreeWidgetItem()
            child_node.setText(0, str(lift))
            child_node.setText(1, str(e1RM))
            child_node.setText(2, str(e1RM_pre_30d))
            e1RM_node.addChild(child_node)

        volume_node = QTreeWidgetItem()
        volume_node.setText(0, 'Volume:')
        for cat, cat_data in cur_wkt.groupby('Category'):
            vol = sum(cat_data.Volume)
            if pre_30d.Category.isin([cat]).any():
                vol_pre_30d = round(pre_30d[pre_30d.Category == cat].resample('1d', how=np.sum).Volume.mean())
            else:
                vol_pre_30d = ''

            child_node = QTreeWidgetItem()
            child_node.setText(0, str(cat))
            child_node.setText(1, str(vol))
            child_node.setText(2, str(vol_pre_30d))
            volume_node.addChild(child_node)

        nt_node = QTreeWidgetItem()
        nt_node.setText(0, 'Normalized Tonnage:')
        for cat, cat_data in cur_wkt.groupby('Category'):
            nt = round(analysis.calc_normalized_tonnage(cat_data).sum())
            if pre_30d.Category.isin([cat]).any():
                nt_pre_30d = round(analysis.calc_normalized_tonnage(
                    pre_30d[pre_30d.Category == cat]).resample('1d', how=np.sum).mean())
            else:
                nt_pre_30d = ''

            child_node = QTreeWidgetItem()
            child_node.setText(0, str(cat))
            child_node.setText(1, str(nt))
            child_node.setText(2, str(nt_pre_30d))
            nt_node.addChild(child_node)

        # TODO: Figure out why fatigue isn't being normalized to weekly average
        fatigue_node = QTreeWidgetItem()
        fatigue_node.setText(0, 'Fatigue:')
        for slot, slot_data in cur_wkt.groupby('Slot'):
            fatigue = analysis.get_fatigue(slot_data)
            fatigue = fatigue.resample('1d', how=np.sum)
            fatigue = fatigue.fillna(0).iloc[-7:].sum()

            if pre_30d.Slot.isin([slot]).any():
                fatigue_30d = analysis.get_fatigue(pre_30d[pre_30d.Slot == slot])
                fatigue_30d = fatigue_30d.resample('1d', how=np.sum)
                fatigue_30d = round(fatigue_30d.fillna(0).mean() * 7.0, 1)
            else:
                fatigue_30d = ''

            child_node = QTreeWidgetItem()
            child_node.setText(0, str(slot))
            child_node.setText(1, str(fatigue))
            child_node.setText(2, str(fatigue_30d))
            fatigue_node.addChild(child_node)

        self.log_tree.addTopLevelItems([e1RM_node, volume_node, fatigue_node, nt_node])
        self.log_tree.expandAll()


    @pyqtSlot()
    def log_add_entry(self):
        try:
            date = pd.Timestamp(self.log_date.date().toPyDate())
            lift = self.log_lift.currentText()
            if not self.db.is_valid_lift(lift):
                raise ValueError('{} is not a valid lift'.format(lift))
            raw_sets = self.log_sets.text().split(',')
            parsed_sets = []
            for set in raw_sets:
                parsed_sets.extend(analysis.parse_sets(set.strip()))
            comments = self.log_comments.text()
            for set in parsed_sets:
                weight, reps, rpe = set
                self.db.add_set(date, lift, weight, reps, rpe, comments)
            self.db.calc_workout_data()
            self.log_date_changed(date - pd.Timedelta(days=1))
            self.log_date_changed(date)
            self.wl_data_changed.emit()
            self.log_lift.setCurrentText('')
            self.log_sets.setText('')
        except (ValueError, TypeError) as err:
            log_err(err, "Couldn't parse log entry")

    @pyqtSlot()
    def log_del_row(self):
        selection = self.log_table.selectedIndexes()
        rows = [index.row() for index in selection]
        # TODO: add enable/disable toggle for delete button based on selection status
        if rows and len(rows) > 0:
            abs_rows = [self.log_get_absolute_index(self._log_date, rel_row) for rel_row in rows]
            self.db.workout_log.reset_index(inplace=True)
            self.db.workout_log.drop(self.db.workout_log.index[abs_rows], inplace=True)
            self.db.workout_log.set_index('Date', inplace=True)

            self.log_table.model().removeDataFrameRows(rows)
            self._log_unsynced_changes = True
            self._log_unsaved_changes = True

    @pyqtSlot()
    def log_values_changed(self):
        table_data = self._log_table_model.dataFrame()
        idx = table_data.index
        abs_idx = self.log_get_absolute_index(self._log_date, idx)
        if not table_data.empty:
            filtered_master = self.db.workout_log.iloc[abs_idx].reset_index()[table_data.keys()]
            if not table_data.equals(filtered_master):
                self._log_unsynced_changes = True
                self._log_unsaved_changes = True
                return True
            else:
                return False
        else:
            return False

    @pyqtSlot()
    def log_sync_changes(self, dont_prompt=False):
        if self._log_unsynced_changes and (self.prompt_save_changes() or dont_prompt):
            new_data = self._log_table_model.dataFrame()
            self.db.merge_workout_data(new_data, self._log_date)
            self._log_unsynced_changes = False

    @pyqtSlot()
    def log_save_changes(self):
        if self._log_unsaved_changes and self.dialog_save():
            if self._log_unsynced_changes:
                self.log_sync_changes(dont_prompt=True)
            self.db.save_to_disk()
            self._log_unsaved_changes = False

    @pyqtSlot()
    def log_import(self):
        pass

    @pyqtSlot()
    def log_date_changed(self, date):
        if self._log_unsynced_changes:
            self.log_sync_changes()
        if isinstance(date, pd.Timestamp) and pd.notnull(date) and self._log_date != date:
            self._log_date = date
            if self.log_date.date().toPyDate() != date.to_pydatetime():
                self.log_date.setDate(QDate(date.to_datetime()))
            if self.log_cal.selectedDate().toPyDate() != date.to_pydatetime():
                self.log_cal.setSelectedDate(QDate(date.to_datetime()))
            self.log_data_changed.emit()

    @pyqtSlot()
    def log_date_selection_changed(self):
        try:
            date = pd.Timestamp(self.log_date.date().toPyDate())
            self.log_date_changed(date)
        except (ValueError, TypeError) as err:
            log_err(err, "Couldn't convert log_date's value to a date... skipping")

    @pyqtSlot()
    def log_cal_selection_changed(self):
        try:
            date = pd.Timestamp(self.log_cal.selectedDate().toPyDate())
            self.log_date_changed(date)
        except (ValueError, TypeError) as err:
            log_err(err, "Couldn't convert log_cal's value to a date... skipping")

    @pyqtSlot()
    def log_populate_lifts_box(self):
        lifts = [str(lift) for lift in self.db.lift_types.Lift]
        self.log_lift.addItems(lifts)

    def log_get_absolute_index(self, date, offset):
        dat = self.db.workout_log.reset_index()
        dat = dat[dat.Date == date]
        if not dat.empty:
            return dat.index[offset]
        else:
            return []

    @pyqtSlot()
    def log_tab_gain_focus(self):
        self.log_data_changed.emit()

    @pyqtSlot()
    def log_tab_lose_focus(self):
        self.log_save_changes()

    # -----
    # Build Tab
    # -----

    # TODO: recalc e1RM in log table after value changed, also recalc curr workout stats
    # TODO: e1RM Calculator button
    # TODO: Clear button only clears current build_tab and deletes tab
    # TODO: test/implement delete button
    # TODO: Quick add button

    def build_filter(self):
        filter_string = self.build_search.text()
        if not filter_string:
            filter_string = ''
        matching_indices = self.db.lift_types.Lift.map(lambda val: filter_string.lower() in val.lower())
        dat = self.db.lift_types[matching_indices]
        self.build_tree_populate(dat)

    @pyqtSlot()
    def build_tree_populate(self, data=None):
        self.build_lifts.clear()
        if not isinstance(data, pd.DataFrame):
            data = self.db.lift_types
        dat = data.reset_index()
        root_children = []
        for cat, cat_group in dat.groupby('Category', as_index=True, sort=False):
            root_child = QTreeWidgetItem()
            root_child.setText(0, cat)
            root_child.setBackground(0, Theme.gray_brush)
            root_child.setBackground(1, Theme.gray_brush)

            lift_children = []
            for lift, _ in cat_group.groupby('Lift'):
                lift_child = QTreeWidgetItem()
                lift_child.setText(1, lift)
                lift_children.append(lift_child)

            root_child.insertChildren(1, lift_children)
            root_children.append(root_child)

        self.build_lifts.setColumnCount(2)
        self.build_lifts.insertTopLevelItems(0, root_children)
        self.build_lifts.expandAll()

    @pyqtSlot(str)
    def build_lift_selected(self, lift=None):
        # TODO: Clean this mess up and make it pretty
        # TODO: Clear current workout stats
        if lift:
            if isinstance(lift, QtWidgets.QTreeWidgetItem):
                txt = lift.text(1)
            elif isinstance(lift, str):
                txt = lift
            else:
                txt = ''
        else:
            txt = self.build_lifts.currentItem().text(1)

        if txt:
            if txt == self._build_cur_lift:
                return
            else:
                self._build_cur_lift = txt
        else:
            return

        pre_workouts = analysis.get_max_e1RM(self.db.workout_log[self.db.workout_log.Lift == self._build_cur_lift])
        pre_e1RM = pre_workouts[pre_workouts.notnull()][-1]
        self._build_tgt_e1rm = pre_e1RM
        self.build_populate_rpe_table()
        self.build_target.setValue(int(self._build_tgt_e1rm))
        self.build_btn_add.setEnabled(True)
        self.build_workout_switch_tables()
        self.build_data_changed.emit()

    @pyqtSlot(int)
    def build_populate_rpe_table(self, tgt_e1rm=None):
        if tgt_e1rm:
            if not isinstance(tgt_e1rm, Number):
                if issubclass(tgt_e1rm, QtWidgets.QWidget):
                    tgt_e1rm = tgt_e1rm.text()
                tgt_e1rm = int(tgt_e1rm)
            if self._build_tgt_e1rm == tgt_e1rm:
                return
            self._build_tgt_e1rm = tgt_e1rm
        elif self._build_tgt_e1rm:
            tgt_e1rm = self._build_tgt_e1rm
        else:
            tgt_e1rm = None
        self.build_RPE.model().update(e1rm=tgt_e1rm)
        self.build_RPE.model().layoutChanged.emit()

    @pyqtSlot(int)
    def build_populate_previous_workout_table(self, lookback=None):
        # TODO: Concatenate current workout onto previous ones to allow more accurate stats
        if not self._build_cur_lift or not self.db.is_valid_lift(self._build_cur_lift):
            return
        elif not lookback:
            lookback = self.build_previous_slider.maximum()

        lift_workouts = self.db.workout_log[self.db.workout_log.Lift == self._build_cur_lift]
        idx_lookback = -1 * (int(self.build_previous_slider.maximum()) + 1) + lookback
        date_idx = sorted(lift_workouts.index.unique())[idx_lookback]
        workout = lift_workouts[lift_workouts.index == date_idx].reset_index()
        workout = workout[['Lift', 'Weight', 'Reps', 'RPE', 'e1RM']]
        self._build_pre_model.setDataFrame(workout, copyDataFrame=False)
        self.build_pre_workout.scrollToBottom()

    @pyqtSlot()
    def build_update_current_workout_stats(self):
        # TODO: Make this reflect changes that are manually entered into the workout table
        if self._build_cur_workout.empty or not self._build_cur_lift or not self.db.is_valid_lift(self._build_cur_lift):
            self.build_stats.model().clear(what='current')
        else:
            self.build_stats.model().update(self._build_cur_workout, self._build_cur_lift)

    @pyqtSlot()
    def build_update_previous_workout_stats(self):
        if not self._build_cur_lift or not self.db.is_valid_lift(self._build_cur_lift):
            self.build_stats.model().clear(what='all')
        else:
            self.build_stats.model().update(cur_lift=self._build_cur_lift)

    @pyqtSlot()
    def build_top_set_selected(self, index):
        if not index.isValid():
            return
        try:
            top_set = index.data().split('(')[0].strip()
            self.build_top_set.setText(top_set)
        except (ValueError, AttributeError):
            pass

    @pyqtSlot()
    def build_add_btn_clicked(self):
        date = pd.Timestamp(self.build_date.text())
        sets = []

        # Warmup sets
        if self.build_warmup.text() == '':    # Auto-calculate warmup if no text present
            warmup = analysis.calc_warmup(self._build_tgt_e1rm, self._build_cur_lift)
            sets.extend(warmup)
        else:
            for set in self.build_warmup.text().split(','):
                parsed = analysis.parse_sets(set.strip(), self._build_tgt_e1rm)
                sets.extend(parsed)

        # Main/Drop sets
        if self.build_top_set.text() != '':
            top_set = analysis.parse_sets(self.build_top_set.text())
            sets.extend(top_set)

            top_weight, top_reps, top_rpe = top_set[-1]
            top_e1rm = analysis.calc_e1RM(self._build_cur_lift, top_weight, top_reps, top_rpe)
            fatigue = self.build_fatigue.value()
            self._build_cur_workout = self.db.make_workout(self._build_cur_lift, sets, date)
            drop_sets = self.build_get_drop_sets(top_set[-1], top_e1rm, fatigue, date)
            self._build_cur_workout = pd.concat((self._build_cur_workout, drop_sets), ignore_index=True)

        # Perform housekeeping
        self.build_clear.setEnabled(True)
        self.build_data_changed.emit()
        self.build_accept.setEnabled(True)
        self._log_unsaved_changes = True

    def build_get_drop_sets(self, top_set, e1RM, fatigue, date):
        dialog = QtWidgets.QDialog()
        dialog.ui = DialogDrop(self.db, top_set, e1RM, date, self._build_cur_lift, fatigue, self._build_cur_workout)
        dialog.ui.setupUi(dialog)
        dialog.exec_()
        dialog.show()
        if bool(dialog.result()):
            return dialog.ui.drop_sets
        else:
            return pd.DataFrame()

    def build_gen_workout_tables(self):
        if self._build_cur_workout.empty:
            return

        for lift in self._build_cur_workout.Lift.unique():
            if lift not in self._build_tabs:
                if len(self._build_tabs) == 0:
                    self.build_tabs.removeTab(0)
                tab = QtWidgets.QWidget()
                tab.setObjectName(lift)
                self.build_tabs.addTab(tab, lift)

                tab_table = QtWidgets.QTableView()

                self._build_cur_model_view.setDataFrame(self._build_cur_workout_view)
                tab_table.setModel(self._build_cur_model_view)
                tab_table.horizontalHeader().setDefaultSectionSize(93)
                tab_table.clicked.connect(self.build_workout_cell_clicked)
                layout = QtWidgets.QVBoxLayout(tab)
                layout.addWidget(tab_table)

                self._build_tabs[lift] = tab_table

        self.build_workout_switch_tables()

    @pyqtSlot()
    def build_workout_switch_tables(self):
        if self._build_cur_workout.empty or len(self._build_tabs) == 0:
            return

        lift_name_tab = -1
        for lift_name in self._build_tabs.keys():
            lift_name_tab += 1
            if lift_name == self._build_cur_lift:
                break

        if lift_name_tab == -1:
            return

        self.build_tabs.setCurrentIndex(lift_name_tab)

        if self._build_cur_lift in self._build_tabs:
            try:
                model = self._build_tabs[self._build_cur_lift].model()
                self.build_save_changes(model.dataFrame())
                self._build_cur_workout_view = self._build_cur_workout[self._build_cur_workout.Lift == self._build_cur_lift]
                self._build_cur_workout_view = self._build_cur_workout_view[['Lift', 'Weight', 'Reps', 'RPE', 'e1RM']]
                model.setDataFrame(self._build_cur_workout_view)
                model.enableEditing(True)
                model.dataChanged.emit()
                model.layoutChanged.emit()
            except Exception as err:
                log_err(err, "Couldn't emit datachanged signals to table model for {}".format(self._build_cur_lift))

    @pyqtSlot()
    def build_lift_tab_changed(self, name):
        if self.build_tabs.currentWidget():
            try:
                lift_name = self.build_tabs.currentWidget().objectName()
                self.build_lift_selected(lift_name)
            except Exception:
                pass

    @pyqtSlot()
    def build_workout_cell_clicked(self):
        self.build_btn_delete.setEnabled(True)

    # TODO: Tap dataChanged to update e1RM col and workout stats

    @pyqtSlot()
    def build_save_changes(self, data):
        if not data.empty:
            lift = data.Lift.unique()[0]
            self._build_cur_workout = self._build_cur_workout[self._build_cur_workout.Lift != lift]
            for row in self._build_cur_workout_view.itertuples():
                new_set = self.db.make_set(lift, row.Weight, row.Reps, self._build_cur_workout.Date.iloc[0], row.RPE)
                self._build_cur_workout = self._build_cur_workout.append(new_set, ignore_index=True)

    @pyqtSlot()
    def build_accept_workout(self):
        if not self._build_cur_workout.empty:
            self._build_cur_workout = self._build_cur_workout.set_index('Date')
            self.db.workout_log = self.db.workout_log.append(self._build_cur_workout)
            self.build_clear_workout()
            self.wl_data_changed.emit()

    @pyqtSlot()
    def build_delete_row(self):
        curr_lift_table = self._build_tabs[self._build_cur_lift]
        rows = [index.row for index in curr_lift_table.currentSelection()]
        self._build_cur_model_view.removeDataFrameRows(rows)
        self.build_btn_delete.setEnabled(False)

    @pyqtSlot()
    def build_clear_workout(self):
        self._build_cur_lift = None
        self._build_cur_workout = pd.DataFrame()
        self.build_tabs.clear()
        self._build_tabs.clear()
        self.build_accept.setEnabled(False)
        self.build_clear.setEnabled(False)
        self.build_stats.model().clear(what='all')
        self.build_RPE.model().clear()
        self.build_pre_workout.model().dataChanged.emit()
        self.build_pre_workout.model().layoutChanged.emit()
        self.build_data_changed.emit()

    # -----
    # Administrative
    # -----

    @pyqtSlot(int)
    def switching_main_tabs_event(self, new_idx=None):
        old_idx = self.tabs.currentIndex()
        self.main_tab_losing_focus_event(old_idx)
        self.main_tab_gaining_focus_event(new_idx)

    def main_tab_losing_focus_event(self, idx_of):
        if idx_of == -1:
            return
        elif idx_of == self.tabs.indexOf(self.dash):
            pass
        elif idx_of == self.tabs.indexOf(self.bw):
            pass
        elif idx_of == self.tabs.indexOf(self.analysis):
            pass
        elif idx_of == self.tabs.indexOf(self.workout_log):
            self.log_tab_lose_focus()
        elif idx_of == self.tabs.indexOf(self.builder):
            pass

    def main_tab_gaining_focus_event(self, idx_of):
        if idx_of == -1:
            return
        elif idx_of == self.tabs.indexOf(self.dash):
            pass
        elif idx_of == self.tabs.indexOf(self.bw):
            pass
        elif idx_of == self.tabs.indexOf(self.analysis):
            pass
        elif idx_of == self.tabs.indexOf(self.workout_log):
            self.log_tab_gain_focus()
        elif idx_of == self.tabs.indexOf(self.builder):
            pass

    @pyqtSlot()
    def prompt_save_changes(self, selected_tab=None):
        dialog = QtWidgets.QDialog()
        dialog.ui = DialogUnsaved()
        dialog.ui.setupUi(dialog)
        dialog.exec_()
        dialog.show()
        return bool(dialog.result())

    def save_logs(self):
        print('Saving')
        do_save = self.dialog_save()
        if do_save:
            self.db.save_to_disk()
        self._log_unsynced_changes = False

    def revert_from_temp(self):
        do_revert, which_log, which_bak = self.dialog_revert()
        if do_revert:
            self.db.restore_from_temp(which_log, which_bak)

    @staticmethod
    def dialog_save():
        dialog = QtWidgets.QDialog()
        dialog.ui = DialogSave()
        dialog.ui.setupUi(dialog)
        dialog.exec_()
        dialog.show()
        return bool(dialog.result())

    def dialog_revert(self):
        dialog = QtWidgets.QDialog()
        dialog.ui = DialogRevert(dialog, self.db)
        dialog.exec_()
        dialog.show()
        which_log = dialog.ui.revert_to_log
        which_file = dialog.ui.revert_to_file
        return bool(dialog.result()), which_log, which_file