focus-tracker / mainwindow.py
mnoorchenar's picture
Tray icon, draggable corner icon, remembered window state
35e213f verified
Raw History Blame Contribute Delete
34.5 kB
"""Main window: live control, editable session list, daily focus, projects, charts and settings."""
import csv
import time
import tkinter as tk
from collections import defaultdict
from datetime import date, datetime, timedelta
from tkinter import filedialog, messagebox, simpledialog, ttk
from db import MIN_SESSION_S
RED, ORANGE, GREEN, GRAY = "#ff453a", "#ff9f0a", "#34c759", "#8e8e93"
ACCENT = "#2f7dd1"
BG, CARD, TEXT, MUTED, LINE = "#f2f2f7", "#ffffff", "#1c1c1e", "#6e6e73", "#e1e1e6"
DISC, TRACK = "#1c1c1e", "#3a3a3c"
RANGES = {"Last 7 days": 7, "Last 30 days": 30, "Last 90 days": 90, "All time": None}
SHOW = {"Today": 1, "Last 7 days": 7, "Last 30 days": 30, "All time": None}
WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
DT_FORMATS = ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M")
CSV_COLUMNS = ["start", "end", "minutes", "plan_minutes", "plan_met", "project"]
def plan_color(elapsed, plan_s):
"""Red below half of the plan, orange up to the plan, green once it is reached."""
frac = elapsed / plan_s
return RED if frac < 0.5 else ORANGE if frac < 1 else GREEN
def split_hours(sessions):
"""Yield (hour_start, seconds, project), cutting every session at hour boundaries."""
for _, project, start, end, _plan in sessions:
cur = datetime.fromtimestamp(start)
stop = datetime.fromtimestamp(end)
while cur < stop:
hour = cur.replace(minute=0, second=0, microsecond=0)
seg = min(stop, hour + timedelta(hours=1))
yield hour, (seg - cur).total_seconds(), project
cur = seg
def fmt_hm(seconds):
m = int(round(seconds / 60))
return f"{m // 60}h {m % 60:02d}m"
def mmss(seconds):
seconds = int(seconds)
return f"{seconds // 60:02d}:{seconds % 60:02d}"
def plan_met(start, end, plan):
return bool(plan) and end - start >= plan * 60
def plan_text(start, end, plan):
if not plan:
return ""
return f"{plan}m ✓" if plan_met(start, end, plan) else f"{plan}m"
def parse_dt(text):
text = text.strip()
for fmt in DT_FORMATS:
try:
return int(datetime.strptime(text, fmt).timestamp())
except ValueError:
pass
raise ValueError(f"Cannot read date/time '{text}' (use YYYY-MM-DD HH:MM[:SS])")
def fmt_dt(ts):
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
def apply_style(win):
st = ttk.Style(win)
st.theme_use("clam")
win.configure(bg=BG)
base = ("Segoe UI", 10)
st.configure(".", background=BG, foreground=TEXT, font=base, bordercolor=LINE, focuscolor=BG)
st.configure("TFrame", background=BG)
st.configure("Card.TFrame", background=CARD, relief="solid", borderwidth=1, bordercolor=LINE)
st.configure("Plain.TFrame", background=CARD)
st.configure("TLabel", background=BG)
st.configure("Card.TLabel", background=CARD)
st.configure("Muted.TLabel", background=BG, foreground=MUTED)
st.configure("CardMuted.TLabel", background=CARD, foreground=MUTED)
st.configure("Title.TLabel", background=CARD, font=("Segoe UI Semibold", 11))
st.configure("Stat.TLabel", background=CARD, font=("Segoe UI Semibold", 18))
st.configure("TNotebook", background=BG, borderwidth=0, tabmargins=(0, 0, 0, 0))
st.configure("TNotebook.Tab", padding=(18, 9), font=("Segoe UI Semibold", 10), background=BG, foreground=MUTED)
st.map("TNotebook.Tab", background=[("selected", CARD)], foreground=[("selected", ACCENT)])
st.configure("Treeview", rowheight=30, background=CARD, fieldbackground=CARD, borderwidth=0, font=base)
st.configure("Treeview.Heading", background="#eceff3", foreground=MUTED, relief="flat",
font=("Segoe UI Semibold", 9), padding=(8, 6))
st.map("Treeview", background=[("selected", ACCENT)], foreground=[("selected", "white")])
st.configure("TButton", padding=(12, 6), background="#eef0f4", borderwidth=1, relief="flat")
st.map("TButton", background=[("active", "#eaf2fb")])
st.configure("Accent.TButton", background=ACCENT, foreground="white", padding=(12, 6))
st.map("Accent.TButton", background=[("active", "#256bb8")])
st.configure("Danger.TButton", foreground=RED)
st.configure("TCheckbutton", background=BG)
st.configure("TCombobox", padding=4, fieldbackground="white")
st.map("TCombobox", fieldbackground=[("readonly", "white")], background=[("readonly", "#eef0f4")])
st.configure("TSpinbox", padding=4, fieldbackground="white")
def make_tree(parent, cols, widths):
frame = ttk.Frame(parent, style="Plain.TFrame")
tree = ttk.Treeview(frame, columns=cols, show="headings", selectmode="browse")
for c, w in zip(cols, widths):
tree.heading(c, text=c)
tree.column(c, width=w, anchor="w")
tree.tag_configure("odd", background="#f7f8fa")
tree.tag_configure("met", foreground="#1e8e3e")
scroll = ttk.Scrollbar(frame, command=tree.yview)
tree.configure(yscrollcommand=scroll.set)
tree.pack(side="left", fill="both", expand=True)
scroll.pack(side="right", fill="y")
return frame, tree
def fill(tree, rows):
"""Replace the rows (iid, values[, tags]) but keep selection and scroll position."""
sel, top = set(tree.selection()), tree.yview()[0]
tree.delete(*tree.get_children())
for i, (iid, values, *extra) in enumerate(rows):
tags = (("odd",) if i % 2 else ()) + tuple(extra[0] if extra else ())
tree.insert("", "end", iid=iid, values=values, tags=tags)
keep = [i for i in sel if tree.exists(i)]
if keep:
tree.selection_set(keep)
tree.yview_moveto(top)
class SessionDialog(tk.Toplevel):
"""Add or edit one session: project, start, end, plan."""
def __init__(self, parent, projects, session=None):
super().__init__(parent)
self.result = None
self.title("Edit session" if session else "Add session")
self.configure(bg=BG)
self.transient(parent)
self.resizable(False, False)
card = ttk.Frame(self, style="Card.TFrame", padding=16)
card.pack(padx=14, pady=14)
_, project, start, end, plan = session or (None, "", int(time.time()) - 1200, int(time.time()), 20)
self.vars = {
"project": tk.StringVar(value=project),
"start": tk.StringVar(value=fmt_dt(start)),
"end": tk.StringVar(value=fmt_dt(end)),
"plan": tk.StringVar(value=str(plan or "")),
}
rows = [("Project", "project"), ("Start", "start"), ("End", "end"), ("Plan (minutes)", "plan")]
for r, (label, key) in enumerate(rows):
ttk.Label(card, text=label, style="Card.TLabel").grid(row=r, column=0, sticky="w", pady=5, padx=(0, 14))
if key == "project":
w = ttk.Combobox(card, textvariable=self.vars[key], values=projects, width=30)
else:
w = ttk.Entry(card, textvariable=self.vars[key], width=32)
w.grid(row=r, column=1, pady=5)
ttk.Label(card, text="Format: YYYY-MM-DD HH:MM:SS", style="CardMuted.TLabel").grid(
row=4, column=1, sticky="w")
btns = ttk.Frame(card, style="Plain.TFrame")
btns.grid(row=5, column=0, columnspan=2, sticky="e", pady=(14, 0))
ttk.Button(btns, text="Cancel", command=self.destroy).pack(side="right")
ttk.Button(btns, text="Save", style="Accent.TButton", command=self.save).pack(side="right", padx=8)
self.bind("<Return>", lambda e: self.save())
self.bind("<Escape>", lambda e: self.destroy())
self.update_idletasks()
self.geometry(f"+{parent.winfo_rootx() + 140}+{parent.winfo_rooty() + 120}")
self.grab_set()
self.wait_window()
def save(self):
try:
project = self.vars["project"].get().strip()
if not project:
raise ValueError("Project name is required.")
start, end = parse_dt(self.vars["start"].get()), parse_dt(self.vars["end"].get())
if end - start < MIN_SESSION_S:
raise ValueError("End must be later than start.")
plan_text_ = self.vars["plan"].get().strip()
plan = int(plan_text_) if plan_text_ else None
if plan is not None and plan < 1:
raise ValueError("Plan must be at least 1 minute (or empty).")
except ValueError as e:
messagebox.showerror("Session", str(e), parent=self)
return
self.result = (project, start, end, plan)
self.destroy()
class MainWindow:
def __init__(self, app):
self.app = app
self.win = win = app.root
apply_style(win)
win.title("Focus Tracker")
h = min(980, int(win.winfo_screenheight() * 0.92))
win.geometry(f"1180x{h}+40+10")
win.minsize(980, 640)
bar = ttk.Frame(win, padding=(16, 12, 16, 4))
bar.pack(fill="x")
ttk.Label(bar, text="Focus Tracker", font=("Segoe UI Semibold", 16)).pack(side="left")
ttk.Button(bar, text="Export CSV", command=self.export_csv).pack(side="right")
ttk.Button(bar, text="Import CSV", command=self.import_csv).pack(side="right", padx=8)
self.tabs = ttk.Notebook(win)
self.tabs.pack(fill="both", expand=True, padx=12, pady=(4, 12))
self._build_home()
self._build_daily()
self._build_projects()
self._build_charts()
self._build_settings()
self.tabs.bind("<<NotebookTabChanged>>", lambda e: self.refresh())
self.refresh()
self._live()
self._auto()
def visible(self):
return self.win.state() != "withdrawn"
def _auto(self):
self.refresh()
self.win.after(30000, self._auto)
# ---- small widget helpers ----
def _spin(self, parent, key, default, lo, hi, cast=int, increment=1, after=None, width=6):
db = self.app.db
var = tk.StringVar(value=str(cast(float(db.get(key, default)))))
def commit(*_):
try:
v = min(hi, max(lo, cast(float(var.get()))))
except ValueError:
v = cast(float(db.get(key, default)))
var.set(str(v))
db.set(key, v)
if after:
after(v)
sp = ttk.Spinbox(parent, from_=lo, to=hi, increment=increment, textvariable=var,
width=width, command=commit)
sp.bind("<Return>", commit)
sp.bind("<FocusOut>", commit)
return sp, var
def _stat_card(self, parent, col, title):
card = ttk.Frame(parent, style="Card.TFrame", padding=(14, 8))
card.grid(row=0, column=col, sticky="nsew", padx=(0 if col == 0 else 10, 0))
parent.columnconfigure(col, weight=1, uniform="stat")
ttk.Label(card, text=title, style="CardMuted.TLabel").pack(anchor="w")
value = ttk.Label(card, style="Stat.TLabel")
value.pack(anchor="w")
sub = ttk.Label(card, style="CardMuted.TLabel")
sub.pack(anchor="w")
return value, sub
# ---- Home ----
def _build_home(self):
f = ttk.Frame(self.tabs, padding=(4, 14, 4, 4))
self.tabs.add(f, text=" Home ")
top = ttk.Frame(f)
top.pack(fill="x")
top.columnconfigure(1, weight=1)
hero = ttk.Frame(top, style="Card.TFrame", padding=14)
hero.grid(row=0, column=0, sticky="ns")
self.ring = c = tk.Canvas(hero, width=200, height=200, bg=CARD, highlightthickness=0)
c.pack()
c.create_oval(4, 4, 196, 196, fill=DISC, outline="")
c.create_oval(20, 20, 180, 180, outline=TRACK, width=10)
self.r_arc = c.create_arc(20, 20, 180, 180, start=90, extent=0, style="arc", outline=GRAY, width=10)
self.r_time = c.create_text(100, 86, text="00:00", fill="white", font=("Segoe UI Semibold", 24))
self.r_sub = c.create_text(100, 126, text="", fill=GRAY, font=("Segoe UI", 9))
self.r_status = ttk.Label(hero, style="Title.TLabel")
self.r_status.pack(pady=(8, 0))
ctrl = ttk.Frame(top, style="Card.TFrame", padding=14)
ctrl.grid(row=0, column=1, sticky="nsew", padx=(10, 0))
grid = ttk.Frame(ctrl, style="Plain.TFrame")
grid.pack(fill="x")
ttk.Label(grid, text="Project", style="CardMuted.TLabel").grid(row=0, column=0, sticky="w")
self.project_var = tk.StringVar(value=self.app.project)
self.project_box = ttk.Combobox(grid, textvariable=self.project_var, width=30, font=("Segoe UI", 11))
self.project_box.grid(row=1, column=0, sticky="w", pady=(2, 8), padx=(0, 20))
for ev in ("<<ComboboxSelected>>", "<Return>", "<FocusOut>"):
self.project_box.bind(ev, self._project_chosen)
ttk.Label(grid, text="Plan (minutes)", style="CardMuted.TLabel").grid(row=0, column=1, sticky="w")
self.plan_spin, self.plan_var = self._spin(grid, "plan_min", 20, 1, 240, after=self.app.set_plan, width=6)
self.plan_spin.grid(row=1, column=1, sticky="w", pady=(2, 8))
self.btn = tk.Button(ctrl, text="Start", font=("Segoe UI Semibold", 13), fg="white", bg=GREEN,
activebackground="#2aa84f", activeforeground="white", relief="flat",
cursor="hand2", pady=4, command=self.app.toggle)
self.btn.pack(fill="x", pady=(0, 8))
hk = ttk.Frame(ctrl, style="Plain.TFrame")
hk.pack(fill="x")
ttk.Label(hk, text="Hotkey", style="CardMuted.TLabel").pack(side="left")
ttk.Label(hk, textvariable=self.app.hotkey_var, style="Title.TLabel").pack(side="left", padx=10)
ttk.Button(hk, text="Change...", command=self.app.capture_hotkey).pack(side="left")
legend = ttk.Frame(ctrl, style="Plain.TFrame")
legend.pack(fill="x", pady=(8, 0))
for color, text in ((RED, "under half of the plan"), (ORANGE, "past half"), (GREEN, "plan reached")):
tk.Label(legend, text="●", fg=color, bg=CARD, font=("Segoe UI", 12)).pack(side="left")
ttk.Label(legend, text=text + " ", style="CardMuted.TLabel").pack(side="left")
stats = ttk.Frame(f)
stats.pack(fill="x", pady=10)
self.s_today = self._stat_card(stats, 0, "Today")
self.s_week = self._stat_card(stats, 1, "This week")
self.s_streak = self._stat_card(stats, 2, "Streak")
self.s_plans = self._stat_card(stats, 3, "Plans met today")
box = ttk.Frame(f, style="Card.TFrame", padding=12)
box.pack(fill="both", expand=True)
head = ttk.Frame(box, style="Plain.TFrame")
head.pack(fill="x", pady=(0, 8))
ttk.Label(head, text="Sessions", style="Title.TLabel").pack(side="left")
self.show_var = tk.StringVar(value="Today")
show = ttk.Combobox(head, textvariable=self.show_var, values=list(SHOW), state="readonly", width=13)
show.pack(side="left", padx=(14, 6))
self.filter_var = tk.StringVar(value="All projects")
self.filter_box = ttk.Combobox(head, textvariable=self.filter_var, state="readonly", width=22)
self.filter_box.pack(side="left")
for w in (show, self.filter_box):
w.bind("<<ComboboxSelected>>", lambda e: self.refresh())
ttk.Button(head, text="Delete", width=8, style="Danger.TButton", command=self.delete_session).pack(side="right")
ttk.Button(head, text="Edit...", width=8, command=self.edit_session).pack(side="right", padx=6)
ttk.Button(head, text="Add...", width=8, command=self.add_session).pack(side="right")
frame, self.sess_tree = make_tree(
box, ("Date", "Start", "End", "Duration", "Plan", "Project"), (110, 90, 90, 100, 90, 360))
frame.pack(fill="both", expand=True)
self.sess_tree.bind("<Double-1>", lambda e: self.edit_session())
self.sess_tree.bind("<Delete>", lambda e: self.delete_session())
# ---- other tabs ----
def _build_daily(self):
f = ttk.Frame(self.tabs, padding=(4, 14, 4, 4))
self.tabs.add(f, text=" Daily ")
bar = ttk.Frame(f)
bar.pack(fill="x", pady=(0, 8))
ttk.Label(bar, text="Range", style="Muted.TLabel").pack(side="left")
self.range_var = tk.StringVar(value="Last 30 days")
box = ttk.Combobox(bar, textvariable=self.range_var, values=list(RANGES), state="readonly", width=14)
box.pack(side="left", padx=8)
box.bind("<<ComboboxSelected>>", lambda e: self.refresh())
frame, self.daily_tree = make_tree(
f, ("Date", "Day", "Focus", "Sessions", "Plans met", "Projects"), (110, 60, 90, 80, 90, 600))
frame.pack(fill="both", expand=True)
def _build_projects(self):
f = ttk.Frame(self.tabs, padding=(4, 14, 4, 4))
self.tabs.add(f, text=" Projects ")
btns = ttk.Frame(f)
btns.pack(fill="x", pady=(0, 8))
ttk.Button(btns, text="Add...", command=self.add_project).pack(side="left")
ttk.Button(btns, text="Rename...", command=self.rename_project).pack(side="left", padx=6)
ttk.Button(btns, text="Delete", style="Danger.TButton", command=self.delete_project).pack(side="left")
frame, self.proj_tree = make_tree(f, ("Project", "Total", "Sessions", "Last used"), (320, 110, 90, 130))
frame.pack(fill="both", expand=True)
def _build_charts(self):
f = ttk.Frame(self.tabs, padding=(4, 8, 4, 4))
self.tabs.add(f, text=" Charts ")
self.charts_tab = f
self.canvas = None # matplotlib is loaded the first time Charts is opened
def _ensure_charts(self):
if self.canvas is None:
import matplotlib
matplotlib.use("TkAgg")
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
self.fig = Figure(figsize=(10, 7), dpi=100, facecolor=BG)
self.canvas = FigureCanvasTkAgg(self.fig, master=self.charts_tab)
self.canvas.get_tk_widget().pack(fill="both", expand=True)
def _build_settings(self):
f = ttk.Frame(self.tabs, padding=20)
self.tabs.add(f, text=" Settings ")
grid = ttk.Frame(f)
grid.pack(anchor="w")
ttk.Label(grid, text="Daily goal (hours)").grid(row=0, column=0, sticky="w", pady=6)
sp, _ = self._spin(grid, "goal_hours", 4, 0.5, 24, cast=float, increment=0.5, after=lambda v: self.refresh())
sp.grid(row=0, column=1, padx=14)
ttk.Label(grid, text="Auto-pause after idle (minutes, 0 = never)").grid(row=1, column=0, sticky="w", pady=6)
sp, _ = self._spin(grid, "idle_minutes", 5, 0, 240)
sp.grid(row=1, column=1, padx=14)
ttk.Checkbutton(f, text="Start with Windows", variable=self.app.autostart_var,
command=self.app.toggle_autostart).pack(anchor="w", pady=(10, 2))
ttk.Checkbutton(f, text="Show the corner icon (drag it anywhere; right-click > Reset icon position)",
variable=self.app.overlay_var,
command=self.app.apply_overlay_visibility).pack(anchor="w", pady=(2, 10))
ttk.Label(f, text=f"Data file: {self.app.db_path}", style="Muted.TLabel").pack(anchor="w", pady=4)
ttk.Label(f, text="Closing this window keeps Focus Tracker running in the system tray (next to the clock).",
style="Muted.TLabel").pack(anchor="w")
ttk.Button(f, text="Quit Focus Tracker", style="Danger.TButton", command=self.app.quit).pack(anchor="w", pady=18)
# ---- project / plan edits ----
def _project_chosen(self, _=None):
name = self.project_var.get().strip()
if name and name != self.app.project:
self.app.set_project(name)
self.refresh()
def add_project(self):
name = simpledialog.askstring("Add project", "Project name:", parent=self.win)
if name and name.strip():
self.app.db.add_project(name.strip())
self.refresh()
@staticmethod
def _selected(tree):
sel = tree.selection()
return sel[0] if sel else None
def rename_project(self):
old = self._selected(self.proj_tree)
if not old:
return
new = simpledialog.askstring("Rename project", f"New name for '{old}':", initialvalue=old, parent=self.win)
if new and new.strip() and new.strip() != old:
new = new.strip()
self.app.db.rename_project(old, new)
if self.app.project == old:
self.app.project = new
self.app.db.set("project", new)
self.refresh()
def delete_project(self):
name = self._selected(self.proj_tree)
if not name:
return
n = self.app.db.con.execute("SELECT COUNT(*) FROM sessions WHERE project=?", (name,)).fetchone()[0]
if not messagebox.askyesno("Delete project", f"Delete '{name}' and its {n} recorded session(s)?\n"
"This cannot be undone.", parent=self.win):
return
if self.app.session and self.app.project == name:
self.app.pause()
self.app.db.delete_project(name)
if self.app.project == name:
self.app.project = ""
self.app.db.set("project", "")
self.app.refresh_today()
self.refresh()
# ---- session edits ----
def _selected_session(self):
sid = self._selected(self.sess_tree)
if not sid:
messagebox.showinfo("Sessions", "Select a session in the list first.", parent=self.win)
return None
if self.app.session and int(sid) == self.app.session[0]:
messagebox.showinfo("Sessions", "Pause the running session first.", parent=self.win)
return None
return next(s for s in self.app.db.sessions(int(time.time())) if s[0] == int(sid))
def add_session(self):
dlg = SessionDialog(self.win, self.app.db.projects())
if dlg.result:
self.app.db.add_session(*dlg.result)
self._changed()
def edit_session(self):
session = self._selected_session()
if not session:
return
dlg = SessionDialog(self.win, self.app.db.projects(), session)
if dlg.result:
self.app.db.update_session(session[0], *dlg.result)
self._changed()
def delete_session(self):
session = self._selected_session()
if session and messagebox.askyesno("Delete session", "Delete the selected session?", parent=self.win):
self.app.db.delete_session(session[0])
self._changed()
def _changed(self):
self.app.refresh_today()
self.refresh()
# ---- import / export ----
def export_csv(self):
path = filedialog.asksaveasfilename(parent=self.win, defaultextension=".csv",
initialfile="focus_sessions.csv", filetypes=[("CSV", "*.csv")])
if not path:
return
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(CSV_COLUMNS)
for _, project, start, end, plan in self.app.db.sessions(int(time.time())):
w.writerow([fmt_dt(start), fmt_dt(end), round((end - start) / 60, 1), plan or "",
int(plan_met(start, end, plan)), project])
messagebox.showinfo("Export", f"Exported to\n{path}", parent=self.win)
def import_csv(self):
path = filedialog.askopenfilename(parent=self.win, filetypes=[("CSV", "*.csv"), ("All files", "*.*")])
if not path:
return
db, added, skipped, bad = self.app.db, 0, 0, []
try:
with open(path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
if not {"start", "end", "project"} <= set(reader.fieldnames or []):
raise ValueError("The file needs the columns: start, end, project "
"(optional: plan_minutes). Use a file made by Export CSV.")
for n, row in enumerate(reader, start=2):
try:
project = (row["project"] or "").strip()
start, end = parse_dt(row["start"]), parse_dt(row["end"])
plan = int(float(row["plan_minutes"])) if (row.get("plan_minutes") or "").strip() else None
if not project or end - start < MIN_SESSION_S:
raise ValueError("empty project or end before start")
except (ValueError, TypeError, KeyError) as e:
bad.append(f"line {n}: {e}")
continue
if db.has_session(project, start, end):
skipped += 1
else:
db.add_session(project, start, end, plan)
added += 1
except (OSError, ValueError, csv.Error) as e:
messagebox.showerror("Import", str(e), parent=self.win)
return
msg = f"Imported {added} session(s), skipped {skipped} duplicate(s)."
if bad:
msg += f"\n{len(bad)} row(s) ignored:\n" + "\n".join(bad[:8]) + ("\n..." if len(bad) > 8 else "")
messagebox.showinfo("Import", msg, parent=self.win)
self._changed()
# ---- live status (every second) ----
def _live(self):
a = self.app
if self.visible():
c = self.ring
if a.session:
el = time.time() - a.session[1]
plan_s = a.session[2] * 60
color, frac = plan_color(el, plan_s), min(1.0, el / plan_s)
c.itemconfig(self.r_time, text=mmss(el))
c.itemconfig(self.r_sub, text=f"of {a.session[2]} min" if el < plan_s else "plan reached!", fill=color)
self.r_status.config(text=f"Focusing on {a.project}")
self.btn.config(text="Pause", bg=ORANGE, activebackground="#e08a00")
else:
color, frac = GRAY, 0.0
c.itemconfig(self.r_time, text="00:00")
c.itemconfig(self.r_sub, text=f"plan {a.plan_min} min", fill=GRAY)
self.r_status.config(text="Paused (idle)" if a.idle_paused else "Ready")
self.btn.config(text="Start", bg=GREEN, activebackground="#2aa84f")
c.itemconfig(self.r_arc, outline=color, extent=-min(359.9, 360 * frac))
c.itemconfig(self.r_time, fill="white")
self.win.after(1000, self._live)
# ---- data refresh ----
def refresh(self):
if not self.visible():
return
a = self.app
now = int(time.time())
sessions = a.db.sessions(now)
today = date.today()
days = RANGES[self.range_var.get()]
first = today - timedelta(days=days - 1) if days else (
datetime.fromtimestamp(sessions[0][2]).date() if sessions else today)
since = datetime.combine(first, datetime.min.time())
info = defaultdict(lambda: {"secs": 0.0, "n": 0, "met": 0, "proj": defaultdict(float)})
for _, _project, start, end, plan in sessions:
d = info[datetime.fromtimestamp(start).date()]
d["n"] += 1
d["met"] += plan_met(start, end, plan)
segs = list(split_hours(sessions))
for hour, secs, project in segs:
d = info[hour.date()]
d["secs"] += secs
d["proj"][project] += secs
self._refresh_home(sessions, info, today)
self._refresh_daily(info, first, today)
self._refresh_projects(sessions)
if self.tabs.select() == str(self.charts_tab):
self._draw_charts(info, [s for s in segs if s[0] >= since], first, today)
def _refresh_home(self, sessions, info, today):
a = self.app
projects = a.db.projects()
self.project_box.config(values=projects)
self.filter_box.config(values=["All projects"] + projects)
if self.filter_var.get() not in self.filter_box.cget("values"):
self.filter_var.set("All projects")
if self.win.focus_get() is not self.project_box:
self.project_var.set(a.project)
if self.win.focus_get() is not self.plan_spin:
self.plan_var.set(str(a.plan_min))
d = today if info[today]["secs"] else today - timedelta(days=1)
streak = 0
while info[d]["secs"] > 0:
streak += 1
d -= timedelta(days=1)
week_start = today - timedelta(days=today.weekday())
week = sum(v["secs"] for k, v in info.items() if k >= week_start)
goal = float(a.db.get("goal_hours", 4)) * 3600
t = info[today]
self.s_today[0].config(text=fmt_hm(t["secs"]))
self.s_today[1].config(text=f"{min(100, int(100 * t['secs'] / goal))}% of {fmt_hm(goal)} goal")
self.s_week[0].config(text=fmt_hm(week))
self.s_week[1].config(text=f"since {week_start.strftime('%b %d')}")
self.s_streak[0].config(text=f"{streak} day{'s' if streak != 1 else ''}")
self.s_streak[1].config(text="in a row")
self.s_plans[0].config(text=f"{t['met']} / {t['n']}")
self.s_plans[1].config(text="reached the plan")
days = SHOW[self.show_var.get()]
since = datetime.combine(today - timedelta(days=days - 1), datetime.min.time()).timestamp() if days else 0
wanted = self.filter_var.get()
rows = []
for sid, project, start, end, plan in reversed(sessions):
if end < since or (wanted != "All projects" and project != wanted):
continue
s, e = datetime.fromtimestamp(start), datetime.fromtimestamp(end)
running = a.session is not None and sid == a.session[0]
rows.append((str(sid), (s.strftime("%Y-%m-%d"), s.strftime("%H:%M:%S"),
"running" if running else e.strftime("%H:%M:%S"), fmt_hm(end - start),
plan_text(start, end, plan), project),
("met",) if plan_met(start, end, plan) else ()))
fill(self.sess_tree, rows)
def _refresh_daily(self, info, first, today):
rows = []
for i in range((today - first).days + 1):
d = today - timedelta(days=i)
v = info.get(d)
if not v or not v["secs"]:
continue
projects = ", ".join(f"{p} {fmt_hm(s)}" for p, s in sorted(v["proj"].items(), key=lambda kv: -kv[1]))
rows.append((d.isoformat(), (d.isoformat(), WEEKDAYS[d.weekday()], fmt_hm(v["secs"]),
v["n"], f"{v['met']}/{v['n']}", projects)))
fill(self.daily_tree, rows)
def _refresh_projects(self, sessions):
total, count, last = defaultdict(float), defaultdict(int), {}
for _, project, start, end, _ in sessions:
total[project] += end - start
count[project] += 1
last[project] = max(last.get(project, 0), start)
rows = []
for p in self.app.db.projects():
when = datetime.fromtimestamp(last[p]).strftime("%Y-%m-%d") if p in last else "-"
rows.append((p, (p, fmt_hm(total[p]), count[p], when)))
fill(self.proj_tree, rows)
def _draw_charts(self, info, segs, first, today):
import matplotlib.dates as mdates
import numpy as np
self._ensure_charts()
per_project = defaultdict(float)
per_week = defaultdict(float)
heat = np.zeros((7, 24))
for hour, secs, project in segs:
per_project[project] += secs
heat[hour.weekday(), hour.hour] += secs / 3600
per_week[hour.date() - timedelta(days=hour.weekday())] += secs
fig = self.fig
fig.clear()
goal = float(self.app.db.get("goal_hours", 4))
ax1, ax2, ax3, ax4 = fig.subplots(2, 2).flat
for ax in (ax1, ax2, ax3, ax4):
ax.set_facecolor(CARD)
days = [first + timedelta(days=i) for i in range((today - first).days + 1)][-365:]
hours = [info[d]["secs"] / 3600 if d in info else 0 for d in days]
ax1.bar(days, hours, color=[GREEN if h >= goal else ACCENT for h in hours], width=0.8)
ax1.axhline(goal, color=RED, lw=1, ls="--", label=f"Goal {goal:g}h")
ax1.set_title("Hours per day")
ax1.set_ylabel("hours")
ax1.legend(loc="upper left", frameon=False)
loc = mdates.AutoDateLocator()
ax1.xaxis.set_major_locator(loc)
ax1.xaxis.set_major_formatter(mdates.ConciseDateFormatter(loc))
items = sorted(per_project.items(), key=lambda kv: kv[1])[-10:]
ax2.barh([k for k, _ in items], [v / 3600 for _, v in items], color=ACCENT)
ax2.set_title("Hours per project")
ax2.set_xlabel("hours")
for i, (_, v) in enumerate(items):
ax2.text(v / 3600, i, f" {fmt_hm(v)}", va="center", fontsize=8)
ax2.margins(x=0.25)
im = ax3.imshow(heat, aspect="auto", cmap="Blues")
ax3.set_title("When you focus (hours)")
ax3.set_yticks(range(7), WEEKDAYS)
ax3.set_xticks(range(0, 24, 3), [f"{h:02d}:00" for h in range(0, 24, 3)])
fig.colorbar(im, ax=ax3, pad=0.02).set_label("hours")
weeks = sorted(per_week)[-12:]
ax4.bar([w.strftime("%b %d") for w in weeks], [per_week[w] / 3600 for w in weeks], color=ACCENT)
ax4.set_title("Hours per week")
ax4.set_ylabel("hours")
ax4.tick_params(axis="x", rotation=45)
for ax in (ax1, ax2, ax4):
ax.spines[["top", "right"]].set_visible(False)
fig.tight_layout()
self.canvas.draw_idle()