# coding: utf-8
# python 2 only

# Copyright (c) 2026 TormachTips.com. All rights reserved.
# Licensed under the TormachTips Personal Use License.
# Permission is granted only for private personal use and private personal modification.
# No sharing, publication, distribution, resale, sublicensing, screenshots, code excerpts,
# benchmarks, or videos are permitted without prior written permission.
# Requests:         tormach.1100m@gmail.com
# Information page: https://tormachtips.com/plugins.htm

#############################################
##                                         ##
##         File Run History v0.98          ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 0.98 - public beta - 4/6/26

# v0.98
    # (2026-04-06)
    # Added All Time Summary

# v0.97         
    # (2026-04-03)
    # Added separate PathPilot script that runs hourly, so if this one sits 
    # unused we don't miss entries that were rotated out.
       
# v0.96         
    # (2026-04-02)
    # A searchable, sortable and filterable docket of all completed file runs.
    # Script loads Tormach's own file logger and parses it into a more intuitive display. 
    # I am pretty sure PathPilot rotates the log file on occasion, so this script grabs what it 
    # can immediately, creates its own permanent log file, then appends from the OEM script as 
    # necessary. This way, no matter when or how often the OEM log file is truncated, the user 
    # keeps a full log history.

    # Current limitation: Will not append permanent file unless user runs the script occasionally. 
    # So, worst case, user runs it once every six months as a curiosity, PathPilot has potentially 
    # rotated OEM log several times in the interim and runs may be lost. 
    # Suggestion: background thread it. 
    
# Copyright (c) 2026 Nick Smith, tormachtips.com, tormach.1100m@gmail.com.
#
# Free to use, copy, modify, and distribute for personal, non-commercial use only.
# Commercial use is prohibited without prior written permission from Nick Smith.
#
# All copies, substantial portions, and modified versions must retain this notice.
# All attributions and credits must identify Nick Smith of tormachtips.com
# as the original author.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL
# THE AUTHOR OR COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING
# FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.    

import os
import datetime
import calendar
import Tkinter as tk
import tkMessageBox
import ttk
import subprocess

OEM_GCODE_LOG        = "/home/operator/gcode/logfiles/gcode_log.txt"
ARCHIVE_GCODE_LOG    = "/home/operator/gcode/python/tormachtips_gcode_log.txt"
FLAG_GCODE_LOG       = "/home/operator/gcode/python/load_history_flag.txt"
RESULTS_EXCLUDE_FILE = "/home/operator/gcode/python/load_history_excluder.txt"
GCODE_BASE_DIR       = "/home/operator/gcode"
CURRENT_VER          = "0.98"
SCRIPT_NAME          = "File Run History Viewer"
DESCRIPTION          = "Detailed Load History analysis, used with the actual Load History Plugin."
WINDOW_TITLE         = "TormachTips File Run History v" + CURRENT_VER
BG_APP               = "#f0f0f0"  # light gray
BG_HEADER            = "#e0e0e0"  # slightly darker light gray
BG_ACTIVE            = "#90EE90"  # light green
BG_IDLE              = "#ffffff"  # white
TODAY_HIGHLIGHT      = "#0066ff"  # bright medium blue
TOOLTIP_BG           = "#ffffe0"  # pale yellow / light cream
ENTRY_BG             = "#ffffff"  # white
TREE_ROW_BG_ODD      = "#ffffff"  # white
TREE_ROW_BG_EVEN     = "#f2f2f2"  # very light gray
PLACEHOLDER_FG       = "#808080"  # medium gray
ENTRY_FG             = "#000000"  # black
FONT_TITLE_COL       = "#0066cc"  # medium blue
FONT_BG              = "#f8f8f8"  # off-white / very light gray
CELL_WIDTH           = 100
CELL_HEIGHT          = 70
HEADER_PADX          = 10
HEADER_PADY          = 10
ROW_PADY             = 6
BODY_PADY            = 10
DATE_YEAR_MIN        = 2010
DATE_YEAR_MAX        = 2035
ZEBRA_ENABLED        = 0
ONLOAD_POPUP         = 1
FONT_MONTH_TITLE     = ("Helvetica", 16, "bold")
FONT_MONTH_LABEL     = ("Helvetica", 12, "bold")
FONT_MONTH_TEXT_BOLD = ("Helvetica", 10, "bold")
FONT_TOOLTIP         = ("DejaVu Sans Mono", 9)
FONT_TITLE           = ("Arial", 14, "bold")
FONT_MONTH_NAV       = ("Helvetica", 14)
FONT_MONTH_TEXT      = ("Helvetica", 10)
FONT_COUNT           = ("Arial", 10)
STATUS_COMPLETE      = "complete"
STATUS_PARTIAL       = "partial"
SCOPE_CURRENT_DAY    = "Search Current Day"
SCOPE_ALL_TIME       = "Search All Time"
ELAPSED_MODE_GT      = "GT"
ELAPSED_MODE_LT      = "LT"
ELAPSED_PLACEHOLDER  = "120s"
GOTO_DATE_HOLDER     = "YYYY-MM-DD"
SEARCH_PLACEHOLDER   = "myfile.nc"

HELP_TEXT_ELAPSED = (
    "Filter by GREATER THAN or LESSER THAN Elapsed Time\n\n"
    "For example:\n    Filter by GREATER THAN 10m long\n    or\n"
    "    LESSER THAN 30s long\n\n"
    "Enter values in seconds or use h, m, s.\n\n"
    "Examples:\n"
    "  90      = 90 seconds\n"
    "  120s    = 120 seconds\n"
    "  5m      = 5 minutes\n"
    "  1h 10m  = 1 hour 10 minutes\n"
    "  70m     = 1 hour 10 minutes\n"
    "  4200    = 1 hour 10 minutes\n\n"      
    "You can combine this filter with the\n"
    "search filter to the left.\n\n"
    "Example: myfile.nc GREATER THAN 10m.")

HELP_TEXT_SEARCH = (
    "The search filter will match anything found in the log file, on a per-line basis.\n\n"   
    "It can match:\n"
    "  start time\n"
    "  stop time\n"
    "  elapsed text\n"
    "  status such as complete or partial\n"
    "  filename\n"
    "  parts of the filename\n"
    "\n"
    "Examples:\n"
    "  17:03\n"
    "  complete\n"
    "  partial\n"
    "  myfile\n"
    "  myfile.nc\n\n"
    "You can combine this filter with the elapsed time filter to the right.\n\n"
    "Example: myfile.nc GREATER THAN 10m.\n\n"    
    "'Complete' means the program ran to the end (or M30, etc).\n"
    "'Partial' means it was prematurely stopped for any reason (ESC Key, E-Stop, etc).")

class LogArchiver(object):
    @staticmethod
    def sync(live_log_path, archive_log_path):
        if not os.path.isfile(live_log_path):
            return 0
        live_lines = []
        try:
            with open(live_log_path, 'r') as f_live:
                live_lines = f_live.readlines()
        except:
            return 0
        if not os.path.isfile(archive_log_path):
            try:
                with open(archive_log_path, 'w') as f_archive:
                    f_archive.writelines(live_lines)
                return len(live_lines)
            except:
                return 0
        archive_seen = {}
        try:
            with open(archive_log_path, 'r') as f_archive:
                for line in f_archive:
                    archive_seen[line] = True
        except:
            return 0
        new_lines = []
        for line in live_lines:
            if line not in archive_seen:
                new_lines.append(line)
                archive_seen[line] = True
        if not new_lines:
            return 0        
        try:
            with open(archive_log_path, 'a') as f_archive:
                f_archive.writelines(new_lines)
            return len(new_lines)
        except:
            return 0

class StartupNotifier(object):
    @staticmethod
    def build_message(new_count, archive_path, flag_path):
        if os.path.isfile(archive_path):
            try:
                mtime = os.path.getmtime(archive_path)
                last_updated = datetime.datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S')
                archive_msg = "Archive last updated: {}\n".format(last_updated)
            except:
                archive_msg = "Archive last updated: unknown"
        else:
            archive_msg = "New permanent archive created"
        try:
            if os.path.isfile(flag_path):
                mtime = os.path.getmtime(flag_path)
                last_open_dt = datetime.datetime.fromtimestamp(mtime)
                viewer_last = last_open_dt.strftime('%Y-%m-%d %H:%M:%S')
                ago = datetime.datetime.now() - last_open_dt
                if ago.days > 0:
                    ago_str = "{} day{} ago".format(ago.days, "s" if ago.days > 1 else "")
                elif ago.seconds >= 3600:
                    hours = ago.seconds // 3600
                    ago_str = "{} hour{} ago".format(hours, "s" if hours > 1 else "")
                elif ago.seconds >= 60:
                    minutes = ago.seconds // 60
                    ago_str = "{} minute{} ago".format(minutes, "s" if minutes > 1 else "")
                else:
                    ago_str = "just now"
                viewer_msg = "Viewer last opened:   {} ({})".format(viewer_last, ago_str)
            else:
                viewer_msg = "Viewer last opened: never before"
        except:
            viewer_msg = "Viewer last opened: unknown"
        if new_count > 0:
            return "{} new log entries synced.\n\n{}\n{}".format(new_count, archive_msg, viewer_msg)
        else:
            return "Log archive is up to date.\n\n{}\n{}".format(archive_msg, viewer_msg)

    @staticmethod
    def show(root, message):
        dialog = tk.Toplevel(root)
        dialog.title(WINDOW_TITLE)
        dialog.wm_attributes('-topmost', 1)
        frame = tk.Frame(dialog, padx=20, pady=15)
        frame.pack()
        tk.Label(frame, 
                 text=message,
                 justify='left',
                 font=FONT_TOOLTIP,
                 bg=FONT_BG,
                 relief='solid',
                 borderwidth=1,
                 padx=12,
                 pady=10).pack()
        btn = tk.Button(frame, text="OK", width=10, command=dialog.destroy)
        btn.pack(pady=10)
        dialog.bind('<Escape>', lambda event: dialog.destroy())
        dialog.bind('<Return>', lambda event: dialog.destroy())
        dialog.bind('<KP_Enter>', lambda event: dialog.destroy())        
        dialog.bind('<FocusOut>', lambda event: dialog.destroy())
        dialog.update_idletasks()
        x = (root.winfo_screenwidth() - dialog.winfo_width()) // 2
        y = (root.winfo_screenheight() - dialog.winfo_height()) // 3
        dialog.geometry("+{}+{}".format(x, y))
        dialog.transient(root)
        dialog.focus_force()

class ToolTip(object):
    def __init__(self, widget, text):
        self.widget = widget
        self.text = text
        self.tipwindow = None
        self.after_id = None
        self.hover_delay_ms = 500
        self.widget.bind('<Enter>', self.schedule_tip)
        self.widget.bind('<Leave>', self.hide_tip)
        self.widget.bind('<ButtonPress>', self.hide_tip)

    def schedule_tip(self, event=None):
        self.cancel_scheduled_tip()
        if not self.text:
            return
        self.after_id = self.widget.after(self.hover_delay_ms, self.show_tip)

    def cancel_scheduled_tip(self):
        if self.after_id is not None:
            self.widget.after_cancel(self.after_id)
            self.after_id = None

    def show_tip(self, event=None):
        self.after_id = None
        if self.tipwindow or not self.text:
            return
        x = self.widget.winfo_rootx() + 20
        y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
        self.tipwindow = tw = tk.Toplevel(self.widget)
        tw.wm_overrideredirect(True)
        tw.wm_geometry("+%d+%d" % (x, y))
        label = tk.Label(tw,text=self.text,justify='left',background=TOOLTIP_BG,foreground="black",
            relief='solid',borderwidth=1,font=FONT_TOOLTIP,padx=8,pady=6)
        label.pack()

    def hide_tip(self, event=None):
        self.cancel_scheduled_tip()
        tw = self.tipwindow
        self.tipwindow = None
        if tw:
            tw.destroy()

class FileLoadHistoryViewer(object):
    def __init__(self, root, history_file):
        self.root = root
        self.history_file = history_file
        self.current_date = datetime.date.today()
        self.line_search_var = tk.StringVar()
        self.elapsed_mode_var = tk.StringVar()
        self.elapsed_value_var = tk.StringVar()
        self.filter_scope_var = tk.StringVar()
        self.filter_scope_var.trace('w', self._on_scope_changed)
        self.goto_date_var = tk.StringVar()
        self.goto_date_placeholder_active = False
        self.elapsed_placeholder_active = False
        self.elapsed_mode_var.set(ELAPSED_MODE_GT)
        self.filter_scope_var.set(SCOPE_CURRENT_DAY)
        self.search_placeholder_active = False
        self.all_rows = []
        self.rows_by_date = {}
        self.run_count_by_date = {}
        self.available_dates = []
        self.sort_column = 'date'
        self.sort_reverse = False
        self.excluded_paths = {}
        self._load_excluded_paths()
        self.root.title(WINDOW_TITLE)
        self._build_ui()
        self._bind_keys()
        self._load_history_cache()
        self._refresh_view() 
    

    def _get_elapsed_total_for_date(self, chosen_date):
        rows = self.rows_by_date.get(chosen_date, [])
        total_seconds = 0
        for row in rows:
            total_seconds += row[3]
        return total_seconds
    
    def _get_selected_file_path(self):
        item_id = self.tree.focus()
        if not item_id:
            return None
        values = self.tree.item(item_id, 'values')
        if not values or len(values) < 6:
            return None
        raw_path = values[5]
        return self._normalize_path(raw_path)

    def _find_editor_program(self):
        for candidate in ('gedit', 'pluma'):
            try:
                p = subprocess.Popen(
                    ['which', candidate],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE
                )
                out, err = p.communicate()
                if p.returncode == 0 and out and out.strip():
                    return out.strip()
            except:
                pass
        return None

    def _open_selected_file_in_editor(self):
        file_path = self._get_selected_file_path()
        if not file_path:
            return
        if not os.path.isfile(file_path):
            tkMessageBox.showinfo(
                "File Not Found",
                "This file does not exist anymore:\n\n%s" % file_path
            )
            return
        editor_path = self._find_editor_program()
        if not editor_path:
            tkMessageBox.showinfo(
                "Editor Unavailable",
                "Neither gedit nor pluma is available on this system."
            )
            return
        try:
            subprocess.Popen([editor_path, file_path])
        except Exception as e:
            tkMessageBox.showerror(
                "Open File Error",
                "Could not open file in gedit or pluma.\n\n%s" % str(e)
            )

    def _refresh_history(self, event=None):
        new_count = LogArchiver.sync(OEM_GCODE_LOG, ARCHIVE_GCODE_LOG)
        self._load_history_cache()
        if new_count > 0:
            self._refresh_view('Refreshed. %d new entr%s synced.' % (
                new_count,
                'y' if new_count == 1 else 'ies'))
        else:
            self._refresh_view('Refreshed. No new log entries.')
        return "break"
        
    def _normalize_path(self, raw_path):
        if not raw_path:
            return None
        raw_path = raw_path.strip()
        if not raw_path:
            return None
        if os.path.isabs(raw_path):
            full_path = raw_path
        else:
            full_path = os.path.join(GCODE_BASE_DIR, raw_path.lstrip('/'))
        full_path = os.path.normpath(full_path)
        if not full_path.startswith(GCODE_BASE_DIR):
            return None
        return full_path

    def _load_excluded_paths(self):
        excluded = {}
        try:
            f = open(RESULTS_EXCLUDE_FILE, 'r')
            try:
                for line in f:
                    line = line.strip()
                    if not line:
                        continue
                    excluded[os.path.normpath(line)] = True
            finally:
                f.close()
        except:
            pass
        self.excluded_paths = excluded

    def _is_excluded_path(self, raw_path):
        norm = self._normalize_path(raw_path)
        if not norm:
            return False
        return norm in self.excluded_paths

    def _append_excluded_path(self, raw_path):
        norm = self._normalize_path(raw_path)
        if not norm:
            return False
        if self._is_excluded_path(norm):
            return True
        try:
            f = open(RESULTS_EXCLUDE_FILE, 'a')
            try:
                f.write(norm + '\n')
            finally:
                f.close()
            self.excluded_paths[norm] = True
            return True
        except:
            return False

    def _exclude_selected_file(self):
        item_id = self.tree.focus()
        if not item_id:
            return
        values = self.tree.item(item_id, 'values')
        if not values or len(values) < 6:
            return
        raw_path = values[5]
        full_path = self._normalize_path(raw_path)
        if not full_path:
            return
        if self._is_excluded_path(full_path):
            self._show_temporary_message("Already excluded.")
            return
        if not self._append_excluded_path(full_path):
            tkMessageBox.showerror(
                "Exclude Error",
                "Could not write excluded file path to:\n\n%s" % RESULTS_EXCLUDE_FILE
            )
            return
        self._load_history_cache()
        self._refresh_view("Excluded: %s" % os.path.basename(full_path))
        self._show_exclusion_notification(full_path)

    def _show_exclusion_notification(self, full_path):
        basename = os.path.basename(full_path)
        msg = (
            "File successfully excluded:\n\n"
            "  • {}\n\n"
            "Exclusion logged to:\n"
            "  {}\n\n"
            "To remove this exclusion (so the file re-appears in the log view):\n"
            "  1. Open the file above in any text editor (gedit, pluma, etc.)\n"
            "  2. Delete the entire line found above.\n"
            "  3. Save the file.\n\n"
            "Refresh the viewer (or press F5) for the change to take effect."
        ).format(basename, RESULTS_EXCLUDE_FILE, full_path)

        self.show_nice_help_popup("Exclusion Added", msg)
    
    def show_nice_help_popup(self, title, message, wraplength=600):
        dialog = tk.Toplevel(self.root)
        dialog.title(title)
        dialog.wm_attributes('-topmost', 1)
        dialog.resizable(False, False)
        frame = tk.Frame(dialog, padx=15, pady=12, bg=TOOLTIP_BG)
        frame.pack()
        max_wrap = int(self.root.winfo_screenwidth() * 0.9)
        if wraplength is None:
            use_wrap = max_wrap
        else:
            use_wrap = min(wraplength, max_wrap)
        msg_label = tk.Label(frame,text=message,justify='left',anchor='w',background=TOOLTIP_BG,foreground="black",
            font=FONT_TOOLTIP,relief='solid',borderwidth=1,padx=12,pady=10,wraplength=use_wrap)
        msg_label.pack(anchor='w')
        btn_frame = tk.Frame(frame, bg=TOOLTIP_BG)
        btn_frame.pack(pady=(12, 0))
        tk.Button(btn_frame, text="OK", width=10, command=dialog.destroy).pack()
        dialog.bind('<Escape>', lambda event: dialog.destroy())
        dialog.bind('<Return>', lambda event: dialog.destroy())
        dialog.bind('<KP_Enter>', lambda event: dialog.destroy())
        dialog.bind('<FocusOut>', lambda event: dialog.destroy())
        dialog.update_idletasks()
        x = (self.root.winfo_screenwidth() - dialog.winfo_width()) // 2
        y = (self.root.winfo_screenheight() - dialog.winfo_height()) // 3
        dialog.geometry("+{}+{}".format(x, y))
        dialog.transient(self.root)
        dialog.focus_force()
    
    def _on_scope_changed(self, *args):
        if hasattr(self, 'tree'):
            self._refresh_view('')
    
    def _set_sort(self, column):
        if self.sort_column == column:
            self.sort_reverse = not self.sort_reverse
        else:
            self.sort_column = column
            self.sort_reverse = False
        self._update_tree_headings()
        self._refresh_view('')

    def _on_tree_right_click(self, event=None):
        item_id = self.tree.identify_row(event.y)
        if not item_id:
            return
        self.tree.selection_set(item_id)
        self.tree.focus(item_id)
        full_path = self._get_selected_file_path()
        if not full_path:
            return
        editor_path = self._find_editor_program()
        file_exists = os.path.isfile(full_path)
        if not file_exists:
            open_btn_text = "File No Longer Available"
            open_btn_state = 'disabled'
        elif not editor_path:
            open_btn_text = "Cannot Edit"
            open_btn_state = 'disabled'
        else:
            open_btn_text = "Open in Text Editor"
            open_btn_state = 'normal'
        dialog = tk.Toplevel(self.root)
        dialog.title("Full Path")
        dialog.wm_attributes('-topmost', 1)
        dialog.resizable(False, False)
        frame = tk.Frame(dialog, padx=15, pady=12, bg=TOOLTIP_BG)
        frame.pack()
        path_box = tk.Text(
            frame,
            height=3,
            width=min(120, max(40, len(full_path) + 2)),
            font=FONT_TOOLTIP,
            background="white",
            foreground="black",
            relief='solid',
            borderwidth=1,
            wrap='none',
            padx=12,
            pady=10
        )       
        path_box.insert('1.0', '\n' + full_path + '\n')
        path_box.config(state='normal')
        path_box.pack(anchor='w', fill='x', pady=(8, 8))
        btn_frame = tk.Frame(frame, bg=TOOLTIP_BG)
        btn_frame.pack(pady=(12, 0))
        tk.Button(
            btn_frame,
            text=open_btn_text,
            width=20,
            state=open_btn_state,
            command=lambda: [dialog.destroy(), self._open_selected_file_in_editor()]
        ).pack(side='left', padx=(0, 8))
        tk.Button(
            btn_frame,
            text="Exclude File",
            width=12,
            command=lambda: [dialog.destroy(), self._exclude_selected_file()]
        ).pack(side='left', padx=(0, 8))
        tk.Button(
            btn_frame,
            text="Close",
            width=10,
            command=dialog.destroy
        ).pack(side='left')
        dialog.bind('<Escape>', lambda event: dialog.destroy())
        dialog.update_idletasks()
        x = (self.root.winfo_screenwidth() - dialog.winfo_width()) // 2
        y = (self.root.winfo_screenheight() - dialog.winfo_height()) // 3
        dialog.geometry("+{}+{}".format(x, y))
        dialog.transient(self.root)
        dialog.focus_force()

    def _sort_rows(self, rows):
        if self.sort_column == 'date':
            keyfunc = lambda row: row[1]        
        elif self.sort_column == 'start':
            keyfunc = lambda row: row[0]
        elif self.sort_column == 'stop':
            keyfunc = lambda row: row[1]
        elif self.sort_column == 'elapsed':
            keyfunc = lambda row: row[3]
        elif self.sort_column == 'status':
            keyfunc = lambda row: self._get_status_from_event(row[4])
        elif self.sort_column == 'file':
            keyfunc = lambda row: row[2].lower()
        else:
            keyfunc = lambda row: row[1]
        return sorted(rows, key=keyfunc, reverse=self.sort_reverse)
    
    def _load_history_cache(self):
        self._load_excluded_paths()
        raw_rows = self._read_history_rows()
        self.all_rows = [row for row in raw_rows if not self._is_excluded_path(row[2])]
        self.all_rows.sort(key=lambda row: row[1], reverse=True)

        self.excluded_count_by_date = {}
        for row in raw_rows:
            if self._is_excluded_path(row[2]):
                d = row[0].date()
                self.excluded_count_by_date[d] = self.excluded_count_by_date.get(d, 0) + 1
        self.rows_by_date = {}
        self.run_count_by_date = {}
        for row in self.all_rows:
            d = row[1].date()
            if d not in self.rows_by_date:
                self.rows_by_date[d] = []
            self.rows_by_date[d].append(row)
            self.run_count_by_date[d] = self.run_count_by_date.get(d, 0) + 1
        self.available_dates = self.run_count_by_date.keys()
        self.available_dates.sort()
        for d in self.rows_by_date:
            self.rows_by_date[d].sort(key=lambda row: row[0])
    
    def _set_goto_date_placeholder(self):
        if self.goto_date_var.get().strip():
            return
        self.goto_date_placeholder_active = True
        self.goto_date_entry.config(fg=PLACEHOLDER_FG)
        self.goto_date_var.set(GOTO_DATE_HOLDER)
    
    def _clear_goto_date_placeholder(self):
        if not self.goto_date_placeholder_active:
            return
        self.goto_date_placeholder_active = False
        self.goto_date_entry.config(fg=ENTRY_FG)
        self.goto_date_var.set('')
    
    def _on_goto_date_focus_in(self, event=None):
        self._clear_goto_date_placeholder()
    
    def _on_goto_date_focus_out(self, event=None):
        if not self.goto_date_var.get().strip():
            self._set_goto_date_placeholder()
    
    def go_to_entered_day(self, event=None):
        if self.goto_date_placeholder_active:
            self.status_label.config(text='Enter a date like YYYY-MM-DD.')
            return
        raw = self.goto_date_var.get().strip()
        if not raw:
            self.status_label.config(text='Enter a date like YYYY-MM-DD.')
            return
        try:
            chosen_date = datetime.datetime.strptime(raw, '%Y-%m-%d').date()
        except ValueError:
            self.status_label.config(text='Invalid date. Use YYYY-MM-DD.')
            return
        self.current_date = chosen_date
        rows = self._read_history_for_date(chosen_date)
        if rows:
            self._refresh_view('Showing selected day.')
            return
        next_date = self._find_next_date_with_entries(chosen_date)
        prev_date = self._find_previous_date_with_entries(chosen_date)
        if next_date is not None:
            self.current_date = next_date
            self._refresh_view('Selected day has no entries. Showing next day with history.')
        elif prev_date is not None:
            self.current_date = prev_date
            self._refresh_view('Selected day has no entries. Showing most recent prior day with history.')
        else:
            self.current_date = chosen_date
            self._refresh_view('No history entries found in log.')
    
    def _show_temporary_message(self, message, ms=2000):
        popup = tk.Toplevel(self.root)
        popup.wm_overrideredirect(1)
        popup.configure(bg=TOOLTIP_BG)
        label = tk.Label(popup,text=message,bg=TOOLTIP_BG,relief='solid',borderwidth=1,padx=10,pady=6)
        label.pack()
        self.root.update_idletasks()
        root_x = self.root.winfo_rootx()
        root_y = self.root.winfo_rooty()
        root_w = self.root.winfo_width()
        popup.update_idletasks()
        popup_w = popup.winfo_width()
        x = root_x + max(20, (root_w - popup_w) // 2)
        y = root_y + 60
        popup.wm_geometry("+%d+%d" % (x, y))
        popup.after(ms, popup.destroy)
        return popup    
    
    def _set_elapsed_placeholder(self):
        if self.elapsed_value_var.get().strip():
            return
        self.elapsed_placeholder_active = True
        self.elapsed_entry.config(fg=PLACEHOLDER_FG)
        self.elapsed_value_var.set(ELAPSED_PLACEHOLDER)
    
    def _set_search_placeholder(self):
        if self.line_search_var.get().strip():
            return
        self.search_placeholder_active = True
        self.line_search_entry.config(fg=PLACEHOLDER_FG)
        self.line_search_var.set(SEARCH_PLACEHOLDER)
    
    def _update_tree_headings(self):
        labels = {'date': 'Date','start': 'Start','stop': 'Stop','elapsed': 'Elapsed','status': 'Status','file': 'File',}
        for key in labels:
            text = labels[key]
            if key == self.sort_column:
                if self.sort_reverse:
                    text += ' ▼'
                else:
                    text += ' ▲'
            heading_text = text
            if key == 'file':
                heading_text = '  ' + text
            heading_anchor = 'w' if key == 'file' else 'center'
            self.tree.heading(key, text=heading_text, anchor=heading_anchor, command=lambda c=key: self._set_sort(c))                
    
    def _clear_search_placeholder(self):
        if not self.search_placeholder_active:
            return
        self.search_placeholder_active = False
        self.line_search_entry.config(fg=ENTRY_FG)
        self.line_search_var.set('')
    
    def _on_search_focus_in(self, event=None):
        self._clear_search_placeholder()
    
    def _on_search_focus_out(self, event=None):
        if not self.line_search_var.get().strip():
            self._set_search_placeholder()        
    
    def _clear_elapsed_placeholder(self):
        if not self.elapsed_placeholder_active:
            return
        self.elapsed_placeholder_active = False
        self.elapsed_entry.config(fg=ENTRY_FG)
        self.elapsed_value_var.set('')
    
    def _on_elapsed_focus_in(self, event=None):
        self._clear_elapsed_placeholder()
    
    def _on_elapsed_focus_out(self, event=None):
        if not self.elapsed_value_var.get().strip():
            self._set_elapsed_placeholder()
    
    def _build_ui(self):
        header = tk.Frame(self.root)
        header.pack(fill='x', padx=HEADER_PADX, pady=HEADER_PADY)
        self.prev_btn = tk.Button(header, text="◀", width=6, command=self.show_previous_day)
        self.prev_btn.pack(side='left')
        self.date_label = tk.Label(header, text="", font=FONT_TITLE)
        self.date_label.pack(side='left', padx=20)
        self.next_btn = tk.Button(header, text="▶", width=6, command=self.show_next_day)
        self.next_btn.pack(side='left')
        self.month_btn = tk.Button(header, text="Month", width=8, underline=0, command=self.open_month_view)
        self.month_btn.pack(side='left', padx=(10, 0))       
        self.goto_date_entry = tk.Entry(    header,    textvariable=self.goto_date_var,    width=12,    bg=ENTRY_BG)
        self.goto_date_entry.pack(side='left', padx=(10, 4))
        self.goto_date_entry.bind('<Return>', self.go_to_entered_day)
        self.goto_date_entry.bind('<KP_Enter>', self.go_to_entered_day)
        self.goto_date_entry.bind('<FocusIn>', self._on_goto_date_focus_in)
        self.goto_date_entry.bind('<FocusOut>', self._on_goto_date_focus_out)
        self.goto_btn = tk.Button(header, text="Go To Day", width=8, underline=0, command=self.go_to_entered_day)
        self.goto_btn.pack(side='left')
        self._set_goto_date_placeholder()             
        self.today_btn = tk.Button(header, text="Today", width=8, underline=0, command=self.go_to_today)
        self.today_btn.pack(side='left', padx=(10, 0))                      
        self.summary_btn = tk.Button(header, text="All Time", width=8, underline=1, command=self.show_all_time_summary)
        self.summary_btn.pack(side='left', padx=(10, 0))        
        filter_bar = tk.Frame(self.root)
        filter_bar.pack(fill='x', padx=HEADER_PADX, pady=(0, ROW_PADY))
        search_label = tk.Label(filter_bar, text='Search:', underline=0)
        search_label.pack(side='left')
        self.line_search_entry = tk.Entry(    filter_bar,    textvariable=self.line_search_var,    width=28,    bg=ENTRY_BG)
        self.line_search_entry.pack(side='left', padx=(4, 4))
        self.line_search_entry.bind('<Return>', self._apply_filters)
        self.line_search_entry.bind('<KP_Enter>', self._apply_filters)
        self.line_search_entry.bind('<FocusIn>', self._on_search_focus_in)
        self.line_search_entry.bind('<FocusOut>', self._on_search_focus_out)
        self._set_search_placeholder()        
        self.search_help_btn = tk.Button(filter_bar, text='?', width=0,command=self._show_search_help)
        self.search_help_btn.pack(side='left', padx=(0, 12))
        ToolTip(self.search_help_btn, HELP_TEXT_SEARCH)
        elapsed_label = tk.Label(filter_bar, text='    Elapsed:', underline=4)
        elapsed_label.pack(side='left')
        self.elapsed_mode_menu = tk.OptionMenu(filter_bar, self.elapsed_mode_var,ELAPSED_MODE_LT, ELAPSED_MODE_GT)
        self.elapsed_mode_menu.pack(side='left', padx=(4, 4))
        self.elapsed_entry = tk.Entry(filter_bar, textvariable=self.elapsed_value_var,width=14, bg=ENTRY_BG)
        self.elapsed_entry.pack(side='left', padx=(0, 8))
        self.elapsed_entry.bind('<Return>', self._apply_filters)
        self.elapsed_entry.bind('<KP_Enter>', self._apply_filters)
        self.elapsed_entry.bind('<FocusIn>', self._on_elapsed_focus_in)
        self.elapsed_entry.bind('<FocusOut>', self._on_elapsed_focus_out)
        self._set_elapsed_placeholder()        
        self.elapsed_help_btn = tk.Button(filter_bar, text='?', width=0,command=self._show_elapsed_help)
        self.elapsed_help_btn.pack(side='left', padx=(0, 8))
        ToolTip(self.elapsed_help_btn, HELP_TEXT_ELAPSED)
        self.count_label = tk.Label(filter_bar, text="", font=FONT_COUNT, anchor='w', justify='left')
        self.count_label.pack(side='left', padx=(12, 0))                
        actions_bar = tk.Frame(self.root)
        actions_bar.pack(fill='x', padx=HEADER_PADX, pady=(0, ROW_PADY))
        scope_label = tk.Label(actions_bar, text='Scope:', underline=2)
        scope_label.pack(side='left')
        self.scope_menu = tk.OptionMenu(actions_bar, self.filter_scope_var,SCOPE_CURRENT_DAY, SCOPE_ALL_TIME)
        self.scope_menu.pack(side='left', padx=(4, 8))
        self.apply_btn = tk.Button(actions_bar, text='Apply', width=8, underline=0, command=self._apply_filters)
        self.apply_btn.pack(side='left', padx=(0, 6))
        self.clear_btn = tk.Button(actions_bar, text='Clear', width=8, underline=0, command=self._clear_filters)
        self.clear_btn.pack(side='left')
        self.refresh_btn = tk.Button(actions_bar, text='Refresh', width=8, underline=0, command=self._refresh_history)
        self.refresh_btn.pack(side='left', padx=(6, 0))        
        self.status_label = tk.Label(self.root, text="", anchor='w', fg='blue')
        self.status_label.pack(fill='x', padx=HEADER_PADX, pady=(0, 5))
        body = tk.Frame(self.root)
        body.pack(fill='both', expand=True, padx=HEADER_PADX, pady=(0, BODY_PADY))
        style = ttk.Style()
        style.configure('Treeview', rowheight=22, font=('Courier', 10))
        style.configure('Treeview.Heading', font=('Arial', 10, 'bold'))
        self.tree_row_bg_odd = TREE_ROW_BG_ODD
        self.tree_row_bg_even = TREE_ROW_BG_EVEN
        self.tree = ttk.Treeview(body,columns=('date', 'start', 'stop', 'elapsed', 'status', 'file'),show='headings')
        self.tree.pack(side='left', fill='both', expand=True)
        self.tree.tag_configure('oddrow', background=self.tree_row_bg_odd)
        self.tree.tag_configure('evenrow', background=self.tree_row_bg_even)
        yscroll = tk.Scrollbar(body, orient='vertical', command=self.tree.yview)
        yscroll.pack(side='right', fill='y')
        self.tree.configure(yscrollcommand=yscroll.set)
        xscroll = tk.Scrollbar(self.root, orient='horizontal', command=self.tree.xview)
        xscroll.pack(fill='x', padx=HEADER_PADX, pady=(0, BODY_PADY))
        self.tree.configure(xscrollcommand=xscroll.set)
        self.tree.heading('date', text='Date', command=lambda: self._set_sort('date'))
        self.tree.heading('start', text='Start', command=lambda: self._set_sort('start'))
        self.tree.heading('stop', text='Stop', command=lambda: self._set_sort('stop'))
        self.tree.heading('elapsed', text='Elapsed', command=lambda: self._set_sort('elapsed'))
        self.tree.heading('status', text='Status', command=lambda: self._set_sort('status'))
        self.tree.heading('file', text='File', anchor='w', command=lambda: self._set_sort('file'))
        self.tree.column('date', width=110, anchor='center', stretch=False)
        self.tree.column('start', width=90, anchor='center', stretch=False)
        self.tree.column('stop', width=90, anchor='center', stretch=False)
        self.tree.column('elapsed', width=100, anchor='center', stretch=False)
        self.tree.column('status', width=100, anchor='center', stretch=False)
        self.tree.column('file', width=300, anchor='w', stretch=False)
        self._update_tree_headings()
        self.tree.bind('<Double-1>', self._on_tree_double_click)
        self.tree.bind('<Button-3>', self._on_tree_right_click)
    
    def _autosize_file_column(self, rows):
        min_width = 300
        max_width = 1400
        char_px = 8
        pad_px = 30
        longest = 0
        for row in rows:
            try:
                file_text = row[2]
            except:
                file_text = ''
            if file_text and len(file_text) > longest:
                longest = len(file_text)
        content_width = (longest * char_px) + pad_px
        fixed_width = (
            self.tree.column('date', 'width') +
            self.tree.column('start', 'width') +
            self.tree.column('stop', 'width') +
            self.tree.column('elapsed', 'width') +
            self.tree.column('status', 'width'))
        self.root.update_idletasks()
        visible_width = self.tree.winfo_width() - fixed_width - 6
        width = max(min_width, content_width, visible_width)
        width = min(width, max_width)
        self.tree.column('file', width=width, stretch=False)

    def _show_search_help(self):
        self.show_nice_help_popup("Search Filter Help", HELP_TEXT_SEARCH)
    
    def _show_elapsed_help(self):
        self.show_nice_help_popup("Elapsed Filter Help", HELP_TEXT_ELAPSED)
    
    def _on_tree_double_click(self, event=None):
        self._open_selected_file_in_editor()
    
    def _bind_keys(self):
        self.root.bind('<Left>',  self._on_left)
        self.root.bind('<Right>', self._on_right)
        self.root.bind('<Home>',  self._on_home)
        self.root.bind('<End>',   self._on_end)
        self.root.bind('<Up>', self._on_up_row)
        self.root.bind('<Down>', self._on_down_row)
        self.root.bind('<Return>', self._on_return_key)
        self.root.bind('<KP_Enter>', self._on_return_key)
        self.scope_menu.bind('<Return>', self._open_scope_menu)
        self.scope_menu.bind('<KP_Enter>', self._open_scope_menu)
        self.root.bind_all('<Alt-m>', self._on_alt_key)
        self.root.bind_all('<Alt-M>', self._on_alt_key)
        self.root.bind_all('<Alt-g>', self._on_alt_key)
        self.root.bind_all('<Alt-G>', self._on_alt_key)
        self.root.bind_all('<Alt-t>', self._on_alt_key)
        self.root.bind_all('<Alt-T>', self._on_alt_key)
        self.root.bind_all('<Alt-s>', self._on_alt_key)
        self.root.bind_all('<Alt-S>', self._on_alt_key)
        self.root.bind_all('<Alt-e>', self._on_alt_key)
        self.root.bind_all('<Alt-E>', self._on_alt_key)
        self.root.bind_all('<Alt-a>', self._on_alt_key)
        self.root.bind_all('<Alt-A>', self._on_alt_key)
        self.root.bind_all('<Alt-c>', self._on_alt_key)
        self.root.bind_all('<Alt-C>', self._on_alt_key)
        self.root.bind_all('<Alt-l>', self._on_alt_key)
        self.root.bind_all('<Alt-L>', self._on_alt_key)
        self.root.bind_all('<Alt-o>', self._on_alt_key)
        self.root.bind_all('<Alt-O>', self._on_alt_key)
        self.root.bind('<F5>', self._refresh_history)
        self.root.bind('<Alt-r>', self._refresh_history)
        self.root.bind('<Alt-R>', self._refresh_history)        
    

    def _on_return_key(self, event=None):
        widget = self.root.focus_get()
        if widget == self.scope_menu:
            return self._open_scope_menu(event)
        if widget in (self.goto_date_entry, self.line_search_entry, self.elapsed_entry):
            return
        if self._tree_has_selected_row():
            return self._on_open_selected_row(event)
        return 'break'
    
    def _open_scope_menu(self, event=None):
        self.scope_menu.focus_set()
        try:
            self.scope_menu.event_generate('<space>')
        except:
            pass
        return 'break'
        
    def _on_open_selected_row(self, event=None):
        self._on_tree_double_click()
        return 'break'

    def _on_up_row(self, event=None):
        self._move_tree_selection(-1)
        return 'break'

    def _on_down_row(self, event=None):
        self._move_tree_selection(1)
        return 'break'

    def _move_tree_selection(self, delta):
        items = self.tree.get_children('')
        if not items:
            return
        current = self.tree.focus()
        if current not in items:
            index = 0 if delta > 0 else len(items) - 1
        else:
            index = items.index(current) + delta
            if index < 0:
                index = 0
            if index >= len(items):
                index = len(items) - 1
        item_id = items[index]
        self.tree.focus(item_id)
        self.tree.selection_set(item_id)
        self.tree.see(item_id)

    def _on_alt_key(self, event=None):
        keysym = ''
        if event is not None:
            keysym = event.keysym.lower()
        if keysym == 'm':
            self.open_month_view()
        elif keysym == 'g':
            self.goto_date_entry.focus_set()
            self.goto_date_entry.icursor('end')
        elif keysym == 't':
            self.go_to_today()
        elif keysym == 's':
            self.line_search_entry.focus_set()
            self.line_search_entry.icursor('end')
        elif keysym == 'e':
            self.elapsed_entry.focus_set()
            self.elapsed_entry.icursor('end')
        elif keysym == 'a':
            self._apply_filters()
        elif keysym == 'c':
            self._clear_filters()
        elif keysym == 'l':
            self.show_all_time_summary()            
        elif keysym == 'o':
            self.tree.selection_remove(self.tree.selection())            
            self.scope_menu.focus_set()            
        return 'break'
        
    def _on_left(self, event):
        self.show_previous_day()
    
    def _on_right(self, event):
        self.show_next_day()
    
    def _on_home(self, event=None):
        if self._tree_has_selected_row():
            self._select_tree_boundary_row(0)
            return 'break'
        self.show_first_day()

    def _on_end(self, event=None):
        if self._tree_has_selected_row():
            self._select_tree_boundary_row(-1)
            return 'break'
        self.show_last_day()
    

    def _tree_has_selected_row(self):
        item_id = self.tree.focus()
        if not item_id:
            return False
        return item_id in self.tree.get_children('')

    def _select_tree_boundary_row(self, index):
        items = self.tree.get_children('')
        if not items:
            return
        item_id = items[index]
        self.tree.focus(item_id)
        self.tree.selection_set(item_id)
        self.tree.see(item_id)

    def _apply_filters(self, event=None):
        self._refresh_view('')
    
    def _clear_filters(self):
        self.line_search_var.set('')
        self.elapsed_mode_var.set(ELAPSED_MODE_GT)
        self.elapsed_value_var.set('')
        self.filter_scope_var.set(SCOPE_CURRENT_DAY)
        self.goto_date_var.set('')
        self._set_search_placeholder()
        self._set_goto_date_placeholder()
        self._set_elapsed_placeholder()
        self._refresh_view('')
        self.tree.focus_set()
    
    def go_to_today(self):
        today = datetime.date.today()
        today_rows = self._read_history_for_date(today)
        if today_rows:
            self.current_date = today
            self._refresh_view('Showing today.')
            return
        next_date = self._find_next_date_with_entries(today)
        prev_date = self._find_previous_date_with_entries(today)
        if next_date is not None:
            self.current_date = next_date
            self._refresh_view('Today has no entries. Showing next day with history.')
        elif prev_date is not None:
            self.current_date = prev_date
            self._refresh_view('Today has no entries. Showing most recent prior day with history.')
        else:
            self.current_date = today
            self._refresh_view('No history entries found in log.')
    
    def show_previous_day(self):
        prev_date = self._find_previous_date_with_entries(self.current_date)
        if prev_date is None:
            self._refresh_view('Beginning of log.')
            return
        self.current_date = prev_date
        self._refresh_view('')
    
    def show_next_day(self):
        next_date = self._find_next_date_with_entries(self.current_date)
        if next_date is None:
            self._refresh_view('End of log.')
            return
        self.current_date = next_date
        self._refresh_view('')
    
    def show_first_day(self):
        first_date = self._get_first_date_with_entries()
        if first_date is None:
            self._refresh_view('No history entries found in log.')
            return
        self.current_date = first_date
        self._refresh_view('Beginning of log.')
    
    def show_last_day(self):
        last_date = self._get_last_date_with_entries()
        if last_date is None:
            self._refresh_view('No history entries found in log.')
            return
        self.current_date = last_date
        self._refresh_view('End of log.')
    
    def open_month_view(self):
        popup = self._show_temporary_message("Building month view. This may take a few seconds.\n\nToggling between months will likewise take some time.",3000)
        self.root.update()
        MonthView(self)
        if popup.winfo_exists():
            popup.destroy()
    
    def _parse_elapsed_filter(self):
        if self.elapsed_placeholder_active:
            return None
        raw = self.elapsed_value_var.get().strip().lower()
        if not raw:
            return None
        total = 0
        number = ''
        i = 0
        while i < len(raw):
            ch = raw[i]
            if ch.isdigit():
                number += ch
            elif ch in (' ', '\t'):
                pass
            elif ch in ('h', 'm', 's'):
                if not number:
                    return None
                value = int(number)
                if ch == 'h':
                    total += value * 3600
                elif ch == 'm':
                    total += value * 60
                else:
                    total += value
                number = ''
            else:
                return None
            i += 1
        if number:
            total += int(number)
        return total
    
    def _get_status_from_event(self, event_text):
        event_lower = event_text.lower()
        if 'complete run' in event_lower:
            return STATUS_COMPLETE
        if 'partial run' in event_lower:
            return STATUS_PARTIAL
        return ''
    
    def _build_search_text_for_row(self, row):
        start_time, stop_time, full_path, elapsed_seconds, event_text = row
        status = self._get_status_from_event(event_text)
        return '%s %s %s %s %s %s' % (
            stop_time.strftime('%Y-%m-%d').lower(),
            start_time.strftime('%H:%M:%S').lower(),
            stop_time.strftime('%H:%M:%S').lower(),
            self._format_elapsed(elapsed_seconds).lower(),
            status,
            full_path.lower()        )
    
    def _row_matches_filters(self, row):
        start_time, stop_time, full_path, elapsed_seconds, event_text = row
        text_filter = ''
        if not self.search_placeholder_active:
            text_filter = self.line_search_var.get().strip().lower()
        if text_filter:
            line_text = self._build_search_text_for_row(row)
            if text_filter not in line_text:
                return False
        elapsed_limit = self._parse_elapsed_filter()
        if elapsed_limit is not None:
            mode = self.elapsed_mode_var.get().strip().upper()
            if mode == ELAPSED_MODE_LT:
                if not (elapsed_seconds < elapsed_limit):
                    return False
            else:
                if not (elapsed_seconds > elapsed_limit):
                    return False
        return True
    
    def _read_history_rows(self):
        results = []
        if not os.path.isfile(self.history_file):
            return results
        active_runs = {}
        try:
            f = open(self.history_file, 'r')
            try:
                for raw_line in f:
                    line = raw_line.strip()
                    if not line:
                        continue
                    parts = [p.strip() for p in line.split('|', 3)]
                    if len(parts) != 4:
                        continue
                    timestamp_str, machine_name, file_path, event_text = parts
                    try:
                        event_time = datetime.datetime.strptime(timestamp_str.split('.')[0],'%Y-%m-%d %H:%M:%S')
                    except:
                        continue
                    event_lower = event_text.lower()
                    if event_lower.startswith('cycle start'):
                        active_runs[file_path] = event_time
                        continue
                    if not event_lower.startswith('program stopped'):
                        continue
                    start_time = active_runs.pop(file_path, None)
                    if start_time is None:
                        continue
                    elapsed_seconds = int((event_time - start_time).total_seconds())
                    if elapsed_seconds < 0:
                        continue
                    results.append((start_time, event_time, file_path, elapsed_seconds, event_text))
            finally:
                f.close()
        except:
            return results
        return results
    
    def _read_history_all(self):
        return list(self.all_rows)
    
    def _read_history_for_date(self, target_date):
        return list(self.rows_by_date.get(target_date, []))
    
    def _get_filtered_rows(self):
        if self.filter_scope_var.get() == SCOPE_ALL_TIME:
            rows = self._read_history_all()
        else:
            rows = self._read_history_for_date(self.current_date)
        return [row for row in rows if self._row_matches_filters(row)]
    
    def _get_all_dates_with_entries(self):
        return list(self.available_dates)
    
    def _get_excluded_run_count_for_date(self, target_date):
        """Return how many runs on this exact date are currently excluded (would otherwise display)."""
        return self.excluded_count_by_date.get(target_date, 0)

    def _get_run_count_for_date(self, target_date):
        return self.run_count_by_date.get(target_date, 0)        
    
    def _get_first_date_with_entries(self):
        dates = self._get_all_dates_with_entries()
        if not dates:
            return None
        return dates[0]
    
    def _get_last_date_with_entries(self):
        dates = self._get_all_dates_with_entries()
        if not dates:
            return None
        return dates[-1]
    
    def _find_previous_date_with_entries(self, from_date):
        previous = None
        for d in self.available_dates:
            if d < from_date:
                previous = d
            else:
                break
        return previous
    
    def _find_next_date_with_entries(self, from_date):
        for d in self.available_dates:
            if d > from_date:
                return d
        return None

    def _get_total_elapsed_for_date(self, target_date):
        rows = self._read_history_for_date(target_date)
        return sum(row[3] for row in rows)
    
    def _format_elapsed(self, total_seconds):        
        total_seconds = max(0, int(total_seconds))
        hours = total_seconds // 3600
        minutes = (total_seconds % 3600) // 60
        seconds = total_seconds % 60
        parts = []
        if hours > 0:
            parts.append('%dh' % hours)
        if minutes > 0 or hours > 0:
            parts.append('%dm' % minutes)
        parts.append('%ds' % seconds)
        return ' '.join(parts)
        
    def _basename_for_stats(self, full_path):
        try:
            prefix = GCODE_BASE_DIR.rstrip('/') + '/'
            if full_path.startswith(prefix):
                return full_path[len(GCODE_BASE_DIR):]
            return full_path
        except:
            return full_path

    def _build_all_time_summary_text(self):
        rows = self.all_rows
        if not rows:
            return "No history entries found in log."
        total_runs = len(rows)
        total_elapsed = sum(row[3] for row in rows)
        avg_elapsed = int(round(float(total_elapsed) / total_runs)) if total_runs > 0 else 0
        complete_runs = 0
        partial_runs = 0
        runs_by_file = {}
        elapsed_by_file = {}
        longest_runs = []
        for row in rows:
            start_time, stop_time, full_path, elapsed_seconds, event_text = row
            status = self._get_status_from_event(event_text)
            if status == STATUS_COMPLETE:
                complete_runs += 1
            elif status == STATUS_PARTIAL:
                partial_runs += 1
            runs_by_file[full_path] = runs_by_file.get(full_path, 0) + 1
            elapsed_by_file[full_path] = elapsed_by_file.get(full_path, 0) + elapsed_seconds
            longest_runs.append(row)            
        top_most_run = sorted(
            runs_by_file.items(),
            key=lambda item: (-item[1], self._basename_for_stats(item[0]).lower())
        )[:5]
        top_most_elapsed = sorted(
            elapsed_by_file.items(),
            key=lambda item: (-item[1], self._basename_for_stats(item[0]).lower())
        )[:5]
        top_longest_runs = sorted(
            longest_runs,
            key=lambda row: (-row[3], self._basename_for_stats(row[2]).lower(), row[1])
        )[:5]
        first_run = min(rows, key=lambda row: row[0])
        last_run = max(rows, key=lambda row: row[1])
        lines = []
        lines.append("All-Time Summary")
        lines.append("")
        lines.append("Total runs:           %d" % total_runs)
        lines.append("Complete runs:        %d" % complete_runs)
        lines.append("Partial runs:         %d" % partial_runs)
        lines.append("Total elapsed:        %s" % self._format_elapsed(total_elapsed))
        lines.append("Average elapsed:      %s" % self._format_elapsed(avg_elapsed))
        lines.append("")
        lines.append("First run in archive: %s" % first_run[0].strftime('%Y-%m-%d %H:%M:%S'))
        lines.append("Most recent run:      %s" % last_run[1].strftime('%Y-%m-%d %H:%M:%S'))
        lines.append("")
        lines.append("Top 5 Most Run Programs")
        lines.append("-----------------------")
        if top_most_run:
            for idx, item in enumerate(top_most_run, 1):
                full_path, count = item
                lines.append("%d. %s (%d runs)" % (
                    idx,
                    self._basename_for_stats(full_path),
                    count))
        else:
            lines.append("None")
        lines.append("")
        lines.append("Top 5 Most Total Elapsed")
        lines.append("------------------------")
        if top_most_elapsed:
            for idx, item in enumerate(top_most_elapsed, 1):
                full_path, elapsed_seconds = item
                lines.append("%d. %s (%s)" % (
                    idx,
                    self._basename_for_stats(full_path),
                    self._format_elapsed(elapsed_seconds)))
        else:
            lines.append("None")
        lines.append("")
        lines.append("Top 5 Longest Single Runs")
        lines.append("-------------------------")
        if top_longest_runs:
            for idx, row in enumerate(top_longest_runs, 1):
                lines.append("%d. %s (%s)" % (
                    idx,
                    self._basename_for_stats(row[2]),
                    self._format_elapsed(row[3])))
        else:
            lines.append("None")
        lines.append("")
        lines.append("")
        return '\n'.join(lines)
    
    def show_all_time_summary(self):
        message = self._build_all_time_summary_text()
        dialog = tk.Toplevel(self.root)
        dialog.title("All Time Summary")
        dialog.wm_attributes('-topmost', 1)
        screen_w = self.root.winfo_screenwidth()
        screen_h = self.root.winfo_screenheight()
        win_w = int(screen_w * 0.90)
        win_h = int(screen_h * 0.90)
        x = (screen_w - win_w) // 2
        y = (screen_h - win_h) // 2
        dialog.geometry("%dx%d+%d+%d" % (win_w, win_h, x, y))
        frame = tk.Frame(dialog, padx=15, pady=12, bg=TOOLTIP_BG)
        frame.pack(fill='both', expand=True)
        text_frame = tk.Frame(frame, bg=TOOLTIP_BG)
        text_frame.pack(fill='both', expand=True)
        yscroll = tk.Scrollbar(text_frame, orient='vertical')
        yscroll.pack(side='right', fill='y')
        text = tk.Text(
            text_frame,
            wrap='none',
            font=FONT_TOOLTIP,
            bg=TOOLTIP_BG,
            fg="black",
            relief='solid',
            borderwidth=1,
            padx=12,
            pady=10,
            yscrollcommand=yscroll.set
        )
        text.pack(side='left', fill='both', expand=True)
        yscroll.config(command=text.yview)
        text.insert('1.0', message)
        text.config(state='disabled')
        btn_frame = tk.Frame(frame, bg=TOOLTIP_BG)
        btn_frame.pack(pady=(12, 0))
        tk.Button(btn_frame, text="OK", width=10, command=dialog.destroy).pack()
        dialog.bind('<Escape>', lambda event: dialog.destroy())
        dialog.bind('<Return>', lambda event: dialog.destroy())
        dialog.bind('<KP_Enter>', lambda event: dialog.destroy())
        dialog.transient(self.root)
        dialog.focus_force()
    
    def _refresh_view(self, status_text=None):
        current_day_rows = self._read_history_for_date(self.current_date)
        rows = self._get_filtered_rows()
        rows = self._sort_rows(rows)
        day_total_elapsed = sum(row[3] for row in rows)
        day_total_text = self._format_elapsed(day_total_elapsed)
        if self.filter_scope_var.get() == SCOPE_ALL_TIME:
            total_label_text = 'Filtered Total Elapsed: %s' % day_total_text
        else:
            total_label_text = 'Day Total Elapsed: %s' % day_total_text
        self.date_label.config(text=self.current_date.strftime('%Y-%m-%d (%A)'))
        if self.filter_scope_var.get() == SCOPE_ALL_TIME:
            self.count_label.config(text='Runs: %d (All Time)' % len(rows))
        else:
            self.count_label.config(text='Runs: %d' % len(rows))
        if status_text is not None:
            if status_text:
                self.status_label.config(text='%s    %s' % (status_text, total_label_text))
            else:
                self.status_label.config(text=total_label_text)
        if not os.path.isfile(self.history_file):
            self.status_label.config(text='Log file not found: %s' % self.history_file)
            for item in self.tree.get_children():
                self.tree.delete(item)
            return
        if (not self.elapsed_placeholder_active and
                self.elapsed_value_var.get().strip() and
                self._parse_elapsed_filter() is None):
            self.status_label.config(text='Invalid elapsed filter. Use values like 90, 120s, 5m, 1h 10m.')
            for item in self.tree.get_children():
                self.tree.delete(item)
            return
        for item in self.tree.get_children():
            self.tree.delete(item)
        if not rows:
            if self.filter_scope_var.get() == SCOPE_ALL_TIME:
                self.status_label.config(text='No runs matched the current filters across all time.    %s' % total_label_text)
            elif current_day_rows:
                self.status_label.config(text='No runs matched the current filters.    %s' % total_label_text)
            else:
                self.status_label.config(text='No runs recorded for this date.    %s' % total_label_text)
            return
        if status_text is None:
            self.status_label.config(text=total_label_text)
        self._autosize_file_column(rows)
        for idx, row in enumerate(rows):
            start_time, stop_time, full_path, elapsed_seconds, event_text = row
            status = self._get_status_from_event(event_text)
            values = (
                stop_time.strftime('%Y-%m-%d'),
                start_time.strftime('%H:%M:%S'),
                stop_time.strftime('%H:%M:%S'),
                self._format_elapsed(elapsed_seconds),
                status,
                full_path)
            if ZEBRA_ENABLED:
                if idx % 2 == 0:
                    tag = 'evenrow'
                else:
                    tag = 'oddrow'
                self.tree.insert('', 'end', values=values, tags=(tag,))
            else:
                self.tree.insert('', 'end', values=values)     
                
                
        # Note for excluded files on the current day (only shown in day view)
        if self.filter_scope_var.get() == SCOPE_CURRENT_DAY:
            excl_count = self._get_excluded_run_count_for_date(self.current_date)
            if excl_count > 0:
                current_status = self.status_label.cget("text")
                note = "    ({} excluded run{} hidden)".format(
                    excl_count, "s" if excl_count != 1 else "")
                if note not in current_status:
                    self.status_label.config(text=current_status + note)                

class MonthView(tk.Toplevel):
    def __init__(self, viewer):
        tk.Toplevel.__init__(self, viewer.root)
        self.viewer = viewer
        self.title("Month View")
        self.configure(bg=BG_APP)
        self.transient(viewer.root)
        self.bind('<FocusOut>', self._on_focus_out)
        self.bind('<Escape>', lambda event: self.destroy())
        self.bind('<Left>', self._on_left)
        self.bind('<Right>', self._on_right)        
        self.current_year = viewer.current_date.year
        self.current_month = viewer.current_date.month
        self.month_var = tk.StringVar()
        self.year_var = tk.StringVar()
        self.build_ui()
        self.refresh_calendar()
        self.focus_force()
    
    def _on_focus_out(self, event=None):
        self.after(1, self._maybe_close_on_focus_out)

    def _maybe_close_on_focus_out(self):
        if not self.winfo_exists():
            return
        try:
            focused = self.focus_displayof()
        except:
            focused = None
        if focused is not None:
            widget = focused
            while widget is not None:
                if widget == self:
                    return
                try:
                    parent_name = widget.winfo_parent()
                except:
                    break
                if not parent_name:
                    break
                try:
                    widget = widget._nametowidget(parent_name)
                except:
                    break
        try:
            pointer_x = self.winfo_pointerx()
            pointer_y = self.winfo_pointery()
            left = self.winfo_rootx() - 8
            top = self.winfo_rooty() - 32
            right = self.winfo_rootx() + self.winfo_width() + 8
            bottom = self.winfo_rooty() + self.winfo_height() + 8
            if left <= pointer_x <= right and top <= pointer_y <= bottom:
                return
        except:
            pass
        self.destroy()

    def _on_left(self, event=None):
        self.prev_month()
        return 'break'

    def _on_right(self, event=None):
        self.next_month()
        return 'break'

    def build_ui(self):
        nav = tk.Frame(self, bg=BG_APP)
        nav.pack(pady=8)
        tk.Button(nav,text="◀",font=FONT_MONTH_NAV,width=4,command=self.prev_month).grid(row=0, column=0, padx=8)
        self.month_label = tk.Label(nav, text="", font=FONT_MONTH_TITLE, bg=BG_APP)
        self.month_label.grid(row=0, column=1, padx=(6, 2))
        self.month_entry = tk.Entry(nav, textvariable=self.month_var, width=4, justify="center")
        self.month_entry.grid(row=0, column=2, padx=(0, 10))
        self.month_entry.bind("<Return>", self.apply_month_year)
        self.month_entry.bind("<KP_Enter>", self.apply_month_year)
        self.year_entry = tk.Entry(nav, textvariable=self.year_var, width=6, justify="center")
        self.year_entry.grid(row=0, column=3, padx=(0, 10))
        self.year_entry.bind("<Return>", self.apply_month_year)
        self.year_entry.bind("<KP_Enter>", self.apply_month_year)
        tk.Button(nav,text="Apply",command=self.apply_month_year,font=FONT_MONTH_TEXT).grid(row=0, column=4, padx=4)
        tk.Button(nav,text="Today",command=self.go_to_today,font=FONT_MONTH_TEXT).grid(row=0, column=5, padx=8)
        tk.Button(nav,text="▶",font=FONT_MONTH_NAV,width=4,command=self.next_month).grid(row=0, column=6, padx=8)
        self.calendar_frame = tk.Frame(self, bg=BG_APP)
        self.calendar_frame.pack(padx=10, pady=10)
        self.total_label = tk.Label(self, text="", font=FONT_MONTH_TEXT_BOLD, bg=BG_APP)
        self.total_label.pack(pady=10)
    
    def refresh_calendar(self):
        for widget in self.calendar_frame.winfo_children():
            widget.destroy()
        self.month_label.config(
            text=datetime.date(self.current_year, self.current_month, 1).strftime("%B")
        )
        self.month_var.set(str(self.current_month))
        self.year_var.set(str(self.current_year))
        headers = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
        for col, name in enumerate(headers):
            self.calendar_frame.columnconfigure(col, uniform="col", minsize=CELL_WIDTH)
            tk.Label(
                self.calendar_frame,
                text=name,
                width=10,
                font=FONT_MONTH_TEXT_BOLD,
                bg=BG_HEADER,
                relief="ridge"
            ).grid(row=0, column=col, padx=1, pady=1, sticky="nsew")
        cal = calendar.Calendar(firstweekday=6)
        weeks = cal.monthdayscalendar(self.current_year, self.current_month)
        today = datetime.date.today()
        month_total_runs = 0
        for row, week in enumerate(weeks, start=1):
            self.calendar_frame.rowconfigure(row, uniform="row", minsize=CELL_HEIGHT)
            for col, day in enumerate(week):
                if day == 0:
                    tk.Frame(
                        self.calendar_frame,
                        bg=BG_APP,
                        width=CELL_WIDTH,
                        height=CELL_HEIGHT
                    ).grid(row=row, column=col, padx=2, pady=2, sticky="nsew")
                    continue
                d = datetime.date(self.current_year, self.current_month, day)
                run_count = self.viewer._get_run_count_for_date(d)
                elapsed_total = self.viewer._get_elapsed_total_for_date(d)
                month_total_runs += run_count
                bg = BG_ACTIVE if run_count > 0 else BG_IDLE
                cell = tk.Frame(
                    self.calendar_frame,
                    relief="ridge",
                    borderwidth=1,
                    bg=bg,
                    width=CELL_WIDTH,
                    height=CELL_HEIGHT
                )
                cell.grid(row=row, column=col, padx=2, pady=2, sticky="nsew")
                cell.pack_propagate(False)
                if d == today:
                    cell.config(
                        highlightbackground=TODAY_HIGHLIGHT,
                        highlightcolor=TODAY_HIGHLIGHT,
                        highlightthickness=3
                    )
                self.make_day_cell(cell, day, run_count, elapsed_total, bg)
        self.total_label.config(
            text="Month Total Runs: {}\n\nClick a day to open that day's log view.".format(month_total_runs)
        )
    
    def make_day_cell(self, parent, day, run_count, elapsed_total, bg):
        def handler(event=None, d=day):
            self.open_day(d)
        parent.bind("<Button-1>", handler)
        day_label = tk.Label(
            parent,
            text=str(day),
            font=FONT_MONTH_LABEL,
            bg=bg,
            cursor="hand2"
        )
        day_label.pack(pady=2)
        day_label.bind("<Button-1>", handler)
        if run_count > 0:
            count_text = "{} run for".format(run_count) if run_count == 1 else "{} runs for".format(run_count)
            elapsed_text = self.viewer._format_elapsed(elapsed_total)            
        else:
            count_text = u"\u2014"
            elapsed_text = ""
        count_label = tk.Label(
            parent,
            text=count_text,
            font=FONT_MONTH_TEXT,
            bg=bg,
            cursor="hand2"
        )
        count_label.pack()
        count_label.bind("<Button-1>", handler)
        if elapsed_text:
            elapsed_label = tk.Label(
                parent,
                text=elapsed_text,
                font=FONT_MONTH_TEXT,
                bg=bg,
                cursor="hand2"
            )
            elapsed_label.pack()
            elapsed_label.bind("<Button-1>", handler)
    
    def open_day(self, day):
        chosen_date = datetime.date(self.current_year, self.current_month, day)
        run_count = self.viewer._get_run_count_for_date(chosen_date)
        if run_count <= 0:
            tkMessageBox.showinfo("No Log","No runs found for {:04d}-{:02d}-{:02d}".format(self.current_year, self.current_month, day))
            return
        self.viewer.current_date = chosen_date
        self.viewer._refresh_view('')
        self.destroy()
    
    def apply_month_year(self, event=None):
        try:
            month = int(self.month_var.get().strip())
            year = int(self.year_var.get().strip())
        except ValueError:
            tkMessageBox.showerror("Invalid Entry", "Month and year must be whole numbers.")
            return
        if month < 1 or month > 12:
            tkMessageBox.showerror("Invalid Entry", "Month must be from 1 to 12.")
            return
        if year < DATE_YEAR_MIN or year > DATE_YEAR_MAX:
            tkMessageBox.showerror("Invalid Entry","Year must be from %d to %d." % (DATE_YEAR_MIN, DATE_YEAR_MAX))
            return
        self.current_month = month
        self.current_year = year
        self.refresh_calendar()
    
    def go_to_today(self):
        today = datetime.date.today()
        self.current_year = today.year
        self.current_month = today.month
        self.refresh_calendar()
    
    def prev_month(self):
        if self.current_month == 1:
            self.current_month = 12
            self.current_year -= 1
        else:
            self.current_month -= 1
        self.refresh_calendar()
    
    def next_month(self):
        if self.current_month == 12:
            self.current_month = 1
            self.current_year += 1
        else:
            self.current_month += 1
        self.refresh_calendar()

def main():
    new_count = LogArchiver.sync(OEM_GCODE_LOG, ARCHIVE_GCODE_LOG)
    notif = StartupNotifier.build_message(new_count,ARCHIVE_GCODE_LOG,FLAG_GCODE_LOG)
    root = tk.Tk()
    root.wm_attributes('-zoomed', 1)
    app = FileLoadHistoryViewer(root, ARCHIVE_GCODE_LOG)
    if ONLOAD_POPUP:
        root.after(800, lambda: StartupNotifier.show(root, notif))
    try:
        with open(FLAG_GCODE_LOG, 'w') as f:
            f.write(datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + "\n")
    except:
        pass
    today_rows = app._read_history_for_date(app.current_date)
    if not today_rows:
        next_date = app._find_next_date_with_entries(app.current_date)
        prev_date = app._find_previous_date_with_entries(app.current_date)
        if next_date is not None:
            app.current_date = next_date
            app._refresh_view('Today has no entries. Showing next day with history.')
        elif prev_date is not None:
            app.current_date = prev_date
            app._refresh_view('Today has no entries. Showing most recent prior day with history.')
        else:
            app._refresh_view('No history entries found in log.')
    root.mainloop()

if __name__ == '__main__':
    main()
