# 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

#############################################
##                                         ##
##       Spindle Time By Month v0.98       ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 0.98 - public beta - 4/2/26

# SHOWS SPINDLE RUN TIME BY MONTH
# CAN BE RUN FROM TERMINAL OR "ADMIN HOBBSM" FROM MDI LINE
# CLICKING INDIVIDUAL DAYS WILL RUN THE HOBBS HOURLY SCRIPT

import Tkinter as tk
import tkMessageBox
import calendar
import datetime
import glob
import os
import re
import subprocess

HOBBS_DIR   = "/home/operator/gcode/python/hobbs"
HOUR_SCRIPT = "/home/operator/gcode/python/hobbs_hourly.py"
BG_APP      = "#f0f0f0"
BG_HEADER   = "#e0e0e0"
BG_ACTIVE   = "#90EE90"
BG_IDLE     = "#ffffff"
CELL_WID    = 100
CELL_HEI    = 70
CURRENT_VER = "0.98"
SCRIPT_NAME = "Hobbs Monthly Usage Viewer"
DESCRIPTION = "A monthly display of spindle time."

def extract_total_seconds(line):
    match = re.search(r'total\s*=\s*(\d+)\s*seconds', line)
    return int(match.group(1)) if match else None

def hobbs_filename(year, month, day):
    return os.path.join(HOBBS_DIR,"hobbs_{:04d}-{:02d}-{:02d}.txt".format(year, month, day))

def runtime_from_file(path):
    if not os.path.exists(path):
        return 0
    try:
        first = None
        last = None
        with open(path, "r") as f:
            for line in f:
                value = extract_total_seconds(line)
                if value is not None:
                    if first is None:
                        first = value
                    last = value
        if first is None or last is None:
            return 0
        return max(0, last - first)
    except IOError:
        return 0

def get_daily_runtime(year, month, day):
    return runtime_from_file(hobbs_filename(year, month, day))

def get_grand_total():
    total = 0
    pattern = os.path.join(HOBBS_DIR, "hobbs_*.txt")
    for path in glob.glob(pattern):
        total += runtime_from_file(path)
    return total

def format_runtime(seconds):
    if seconds <= 0:
        return u"\u2014"
    if seconds < 60:
        return "{}s".format(seconds)
    if seconds < 3600:
        return "{}m".format(int(round(seconds / 60.0)))
    return "{:.1f}h".format(seconds / 3600.0)

class HobbsCalendar(tk.Tk):
    def __init__(self):
        try:
            import update_checker
            update_checker.tormachtips(__file__, CURRENT_VER)
        except Exception:
            pass           
        tk.Tk.__init__(self)
        self.title("Spindle Time Summary")
        self.configure(bg=BG_APP)
        today = datetime.date.today()
        self.current_year = today.year
        self.current_month = today.month
        self.month_var = tk.StringVar()
        self.year_var = tk.StringVar()
        self.day_cells = {}
        self.day_order = []
        self.focus_mode = "day"
        self.focus_day = None
        self.focus_nav_index = 0
        self.nav_widgets = []        
        self.build_ui()
        self.refresh_calendar()

    def build_ui(self):
        nav = tk.Frame(self, bg=BG_APP)
        nav.pack(pady=8)
        self.prev_button = tk.Button(nav, text="◀", font=("Helvetica", 14), width=4, command=self.prev_month)
        self.prev_button.grid(row=0, column=0, padx=8)
        self.month_label = tk.Label(nav, text="", font=("Helvetica", 16, "bold"),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)
        self.apply_button = tk.Button(nav, text="Apply", command=self.apply_month_year, font=("Helvetica", 10))
        self.apply_button.grid(row=0, column=4, padx=4)
        self.today_button = tk.Button(nav, text="Today", command=self.go_to_today, font=("Helvetica", 10))
        self.today_button.grid(row=0, column=5, padx=8)
        self.next_button = tk.Button(nav, text="▶", font=("Helvetica", 14), width=4, command=self.next_month)
        self.next_button.grid(row=0, column=6, padx=8)
        self.nav_widgets = [
            self.prev_button,
            self.month_entry,
            self.year_entry,
            self.apply_button,
            self.today_button,
            self.next_button,
        ]
        self.bind_all("<Left>", self.on_left_key)
        self.bind_all("<Right>", self.on_right_key)
        self.bind_all("<Up>", self.on_up_key)
        self.bind_all("<Down>", self.on_down_key)
        self.bind_all("<Return>", self.on_enter_key)
        self.bind_all("<KP_Enter>", self.on_enter_key)
        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=("Helvetica", 12, "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.day_cells = {}
        self.day_order = []            
        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_WID)
            tk.Label(self.calendar_frame, text=name, width=10,font=("Helvetica", 10, "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 = 0
        for row, week in enumerate(weeks, start=1):
            self.calendar_frame.rowconfigure(row, uniform="row", minsize=CELL_HEI)
            for col, day in enumerate(week):
                if day == 0:
                    tk.Frame(self.calendar_frame, bg=BG_APP,width=CELL_WID, height=CELL_HEI).grid(row=row, column=col, padx=2, pady=2, sticky="nsew")
                    continue
                seconds = get_daily_runtime(self.current_year, self.current_month, day)
                month_total += seconds
                bg = BG_ACTIVE if seconds > 0 else BG_IDLE
                cell = tk.Frame(self.calendar_frame, relief="ridge", borderwidth=1, bg=bg,width=CELL_WID, height=CELL_HEI)
                cell.grid(row=row, column=col, padx=2, pady=2, sticky="nsew")
                cell.pack_propagate(False)
                if (day == today.day and self.current_month == today.month and self.current_year == today.year):
                    cell.config(highlightbackground="#0066ff",highlightcolor="#0066ff",highlightthickness=3)
                self.make_day_cell(cell, day, seconds, bg)
                self.day_cells[day] = {"frame": cell, "row": row, "col": col, "bg": bg}
                self.day_order.append(day)                
        grand_total = get_grand_total()
        month_hours = month_total / 3600.0
        grand_hours = grand_total / 3600.0
        self.total_label.config(text="Month Total: {:.1f} hours  ({:,} seconds)\nGrand Total: {:.1f} hours  ({:,} seconds)\n\nwww.tormachtips.com".format(month_hours, month_total,grand_hours, grand_total))
        if self.day_order:
            if self.focus_day not in self.day_cells:
                self.focus_day = today.day if today.day in self.day_cells and self.current_month == today.month and self.current_year == today.year else self.day_order[0]
            self.focus_mode = "day"
            self.apply_focus()      

    def make_day_cell(self, parent, day, seconds, bg):
        def handler(event=None, d=day):
            self.focus_mode = "day"
            self.focus_day = d
            self.apply_focus()            
            self.open_day_file(d)
        parent.bind("<Button-1>", handler)
        day_label = tk.Label(parent, text=str(day), font=("Helvetica", 12, "bold"),bg=bg, cursor="hand2")
        day_label.pack(pady=2)
        day_label.bind("<Button-1>", handler)
        runtime_label = tk.Label(parent, text=format_runtime(seconds),font=("Helvetica", 10),bg=bg, cursor="hand2")
        runtime_label.pack()
        runtime_label.bind("<Button-1>", handler)
        
        

    def apply_focus(self):
        for day, info in self.day_cells.items():
            if self.focus_mode == "day" and day == self.focus_day:
                info["frame"].config(highlightbackground="#ff6600", highlightcolor="#ff6600", highlightthickness=3)
            else:
                if (day == datetime.date.today().day and
                    self.current_month == datetime.date.today().month and
                    self.current_year == datetime.date.today().year):
                    info["frame"].config(highlightbackground="#0066ff", highlightcolor="#0066ff", highlightthickness=3)
                else:
                    info["frame"].config(highlightthickness=0)
        if self.focus_mode == "nav":
            widget = self.nav_widgets[self.focus_nav_index]
            try:
                widget.focus_set()
            except Exception:
                pass
        else:
            self.focus_set()

    def find_day_in_direction(self, start_day, row_delta, col_delta):
        if start_day not in self.day_cells:
            return start_day
        start = self.day_cells[start_day]
        target_row = start["row"] + row_delta
        target_col = start["col"] + col_delta
        best_day = None
        best_score = None
        for day, info in self.day_cells.items():
            if row_delta != 0 and info["row"] != target_row:
                continue
            if col_delta != 0 and info["col"] != target_col:
                continue
            score = abs(info["row"] - target_row) + abs(info["col"] - target_col)
            if best_score is None or score < best_score:
                best_day = day
                best_score = score
        if best_day is not None:
            return best_day
        if row_delta == 0:
            same_row = [(day, info) for day, info in self.day_cells.items() if info["row"] == start["row"]]
            if col_delta < 0:
                candidates = [day for day, info in same_row if info["col"] < start["col"]]
                return max(candidates) if candidates else start_day
            if col_delta > 0:
                candidates = [day for day, info in same_row if info["col"] > start["col"]]
                return min(candidates) if candidates else start_day
        return start_day

    def nav_index_for_col(self, col):
        mapping = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 4, 6: 5}
        return mapping.get(col, 0)

    def day_for_nav_index(self, nav_index):
        preferred_cols = {
            0: [0],
            1: [1, 2],
            2: [2, 3],
            3: [4],
            4: [5, 4],
            5: [6],
        }.get(nav_index, [0])
        top_row = min([info["row"] for info in self.day_cells.values()]) if self.day_cells else None
        if top_row is None:
            return None
        for col in preferred_cols:
            for day, info in sorted(self.day_cells.items()):
                if info["row"] == top_row and info["col"] == col:
                    return day
        return self.day_order[0] if self.day_order else None

    def on_left_key(self, event):
        if self.focus_mode == "day" and self.focus_day is not None:
            self.focus_day = self.find_day_in_direction(self.focus_day, 0, -1)
            self.apply_focus()
            return "break"
        if self.focus_mode == "nav":
            self.focus_nav_index = max(0, self.focus_nav_index - 1)
            self.apply_focus()
            return "break"

    def on_right_key(self, event):
        if self.focus_mode == "day" and self.focus_day is not None:
            self.focus_day = self.find_day_in_direction(self.focus_day, 0, 1)
            self.apply_focus()
            return "break"
        if self.focus_mode == "nav":
            self.focus_nav_index = min(len(self.nav_widgets) - 1, self.focus_nav_index + 1)
            self.apply_focus()
            return "break"

    def on_up_key(self, event):
        if self.focus_mode == "day" and self.focus_day is not None:
            current = self.day_cells[self.focus_day]
            next_day = self.find_day_in_direction(self.focus_day, -1, 0)
            if next_day != self.focus_day:
                self.focus_day = next_day
            else:
                self.focus_mode = "nav"
                self.focus_nav_index = self.nav_index_for_col(current["col"])
            self.apply_focus()
            return "break"

    def on_down_key(self, event):
        if self.focus_mode == "day" and self.focus_day is not None:
            self.focus_day = self.find_day_in_direction(self.focus_day, 1, 0)
            self.apply_focus()
            return "break"
        if self.focus_mode == "nav":
            next_day = self.day_for_nav_index(self.focus_nav_index)
            if next_day is not None:
                self.focus_mode = "day"
                self.focus_day = next_day
                self.apply_focus()
            return "break"

    def on_enter_key(self, event):
        if self.focus_mode == "day" and self.focus_day is not None:
            self.open_day_file(self.focus_day)
            return "break"
        if self.focus_mode == "nav":
            widget = self.nav_widgets[self.focus_nav_index]
            if widget == self.prev_button:
                self.prev_month()
            elif widget == self.next_button:
                self.next_month()
            elif widget == self.apply_button:
                self.apply_month_year()
            elif widget == self.today_button:
                self.go_to_today()
            return "break"        
                        
    def open_day_file(self, day):
        filename = hobbs_filename(self.current_year, self.current_month, day)
        if not os.path.exists(filename):
            tkMessageBox.showinfo("No Log","No log file found for {:04d}-{:02d}-{:02d}".format(self.current_year, self.current_month, day))
            return
        if not os.path.exists(HOUR_SCRIPT):
            tkMessageBox.showerror("Missing Script","Could not find hourly script:\n{}".format(HOUR_SCRIPT))
            return
        try:
            subprocess.Popen(["python", HOUR_SCRIPT, filename])
        except OSError:
            try:
                subprocess.Popen(["/usr/bin/python", HOUR_SCRIPT, filename])
            except OSError:
                tkMessageBox.showerror("Error","Could not launch hourly script:\n{}\n\nwith file:\n{}".format(HOUR_SCRIPT, filename))                
    
    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 < 2020 or year > 2035:
            tkMessageBox.showerror("Invalid Entry", "Year must be from 2020 to 2035.")
            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.focus_day = today.day        
        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():
    app = HobbsCalendar()
    app.mainloop()

if __name__ == "__main__":
    main()
