Uploading to Gitea
This commit is contained in:
9
README.md
Normal file
9
README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Remember to Forget - A Time and Task Tracking Tool That doesn't surveil you.
|
||||
|
||||
---
|
||||
|
||||
It's a dead simple little doodad, mkay. You start a task, it takes a timestamp and puts a label, you end a task it stamps the time. You literally don't need much else. It's a little like interstitial journaling, but for me it's useful for making invoicing simpler.
|
||||
|
||||
I've packaged a python executable and an html version that work as standalones. The base python script is also included for them that feels like fiddling with it. I don't need to.
|
||||
|
||||
If you want to build your own from the .py, use pyinstaller --onefile. EZ PZ.
|
||||
582
RTF-workday-logger.py
Normal file
582
RTF-workday-logger.py
Normal file
@@ -0,0 +1,582 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
REMEMBER TO FORGET // WORKDAY LOGGER
|
||||
Standalone Python app — stdlib only (tkinter).
|
||||
Run with: python workday_logger.py
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog, simpledialog
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ─── COLOUR PALETTE ──────────────────────────────────────────────────────────
|
||||
BG_DARK = "#0a0a0f"
|
||||
BG_PANEL = "#12121a"
|
||||
BG_ELEV = "#1a1a25"
|
||||
NEON_PINK = "#ff00ff"
|
||||
NEON_CYAN = "#00ffff"
|
||||
NEON_LIME = "#39ff14"
|
||||
NEON_YELL = "#ffff00"
|
||||
NEON_RED = "#ff0033"
|
||||
NEON_ORNG = "#ff8800"
|
||||
NEON_PURP = "#bf00ff"
|
||||
TEXT_DIM = "#888899"
|
||||
TEXT_MAIN = "#e0e0e0"
|
||||
|
||||
FONT_MONO = ("Courier", 11)
|
||||
FONT_MONO_SM = ("Courier", 9)
|
||||
FONT_BIG = ("Courier", 13, "bold")
|
||||
|
||||
# ─── DEFAULT CATEGORIES ───────────────────────────────────────────────────────
|
||||
DEFAULT_CATS = [
|
||||
"General", "Admin", "Personal", "Creative", "Family",
|
||||
"Errands", "Chores", "Learning", "Research",
|
||||
"Work", "Ad-Hoc", "Uncategorised", "Miscellaneous", "Surplus to Requirements",
|
||||
]
|
||||
|
||||
|
||||
def fmt_date(dt: datetime) -> str:
|
||||
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
return f"{dt.strftime('%Y-%m-%d')} - {days[dt.weekday()]}"
|
||||
|
||||
|
||||
def fmt_time(dt: datetime) -> str:
|
||||
return dt.strftime("%H:%M")
|
||||
|
||||
|
||||
def today_key() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def calc_hours(start: datetime, end: datetime) -> float:
|
||||
diff = (end - start).total_seconds() / 3600
|
||||
return round(diff * 2) / 2
|
||||
|
||||
|
||||
# ─── MAIN APP ─────────────────────────────────────────────────────────────────
|
||||
class WorkdayLogger(tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title("REMEMBER TO FORGET // WORKLOG")
|
||||
self.configure(bg=BG_DARK)
|
||||
self.minsize(750, 620)
|
||||
|
||||
# State
|
||||
self.current_task = None # dict or None
|
||||
self.days: list[dict] = [] # [{ key, date, lines:[] }]
|
||||
self.active_day_idx = 0
|
||||
self.custom_cats: list[str] = []
|
||||
|
||||
self._build_ui()
|
||||
self._ensure_today()
|
||||
self._refresh_log()
|
||||
|
||||
# ── UI CONSTRUCTION ────────────────────────────────────────────────────────
|
||||
def _build_ui(self):
|
||||
self.rowconfigure(0, weight=1)
|
||||
self.columnconfigure(0, weight=1)
|
||||
|
||||
root_frame = tk.Frame(self, bg=BG_DARK, padx=16, pady=16)
|
||||
root_frame.grid(row=0, column=0, sticky="nsew")
|
||||
root_frame.columnconfigure(0, weight=1)
|
||||
|
||||
# HEADER
|
||||
hdr = tk.Frame(root_frame, bg=BG_PANEL, padx=12, pady=10,
|
||||
highlightbackground=NEON_PINK, highlightthickness=2)
|
||||
hdr.grid(row=0, column=0, sticky="ew", pady=(0, 10))
|
||||
tk.Label(hdr, text="WORKDAY LOGBOOK", font=("Courier", 22, "bold"),
|
||||
fg=NEON_PINK, bg=BG_PANEL).pack()
|
||||
tk.Label(hdr, text="FORGETFUL TASK TRACKER // NO DATA STORED",
|
||||
font=FONT_MONO_SM, fg=NEON_CYAN, bg=BG_PANEL).pack()
|
||||
|
||||
# IDENTITY FIELDS
|
||||
id_frame = tk.Frame(root_frame, bg=BG_DARK)
|
||||
id_frame.grid(row=1, column=0, sticky="ew", pady=(0, 8))
|
||||
id_frame.columnconfigure(1, weight=1)
|
||||
id_frame.columnconfigure(3, weight=1)
|
||||
|
||||
tk.Label(id_frame, text="OPERATOR:", font=FONT_MONO_SM,
|
||||
fg=NEON_CYAN, bg=BG_DARK).grid(row=0, column=0, sticky="w", padx=(0,6))
|
||||
self.var_name = tk.StringVar()
|
||||
tk.Entry(id_frame, textvariable=self.var_name, font=FONT_MONO,
|
||||
bg=BG_ELEV, fg="white", insertbackground="white",
|
||||
relief="flat", highlightbackground=NEON_CYAN,
|
||||
highlightthickness=1).grid(row=0, column=1, sticky="ew")
|
||||
|
||||
tk.Label(id_frame, text=" ORG:", font=FONT_MONO_SM,
|
||||
fg=NEON_CYAN, bg=BG_DARK).grid(row=0, column=2, sticky="w", padx=(10,6))
|
||||
self.var_org = tk.StringVar()
|
||||
tk.Entry(id_frame, textvariable=self.var_org, font=FONT_MONO,
|
||||
bg=BG_ELEV, fg="white", insertbackground="white",
|
||||
relief="flat", highlightbackground=NEON_CYAN,
|
||||
highlightthickness=1).grid(row=0, column=3, sticky="ew")
|
||||
|
||||
# BUTTONS
|
||||
btn_frame = tk.Frame(root_frame, bg=BG_DARK)
|
||||
btn_frame.grid(row=2, column=0, sticky="ew", pady=(0, 8))
|
||||
for col in range(4):
|
||||
btn_frame.columnconfigure(col, weight=1)
|
||||
|
||||
def styled_btn(parent, text, cmd, fg, **kwargs):
|
||||
b = tk.Button(parent, text=text, command=cmd, font=("Courier", 12, "bold"),
|
||||
fg=fg, bg=BG_PANEL, activeforeground="white",
|
||||
activebackground=BG_ELEV, relief="flat",
|
||||
highlightbackground=fg, highlightthickness=2,
|
||||
padx=10, pady=8, cursor="hand2", **kwargs)
|
||||
return b
|
||||
|
||||
styled_btn(btn_frame, "> START TASK <", self._on_start, NEON_LIME)\
|
||||
.grid(row=0, column=0, padx=4, sticky="ew")
|
||||
styled_btn(btn_frame, "> END TASK <", self._on_end, NEON_RED)\
|
||||
.grid(row=0, column=1, padx=4, sticky="ew")
|
||||
styled_btn(btn_frame, "> CATEGORIES <", self._on_cats, NEON_ORNG)\
|
||||
.grid(row=0, column=2, padx=4, sticky="ew")
|
||||
styled_btn(btn_frame, "> + NEW DAY <", self._on_new_day, NEON_YELL)\
|
||||
.grid(row=0, column=3, padx=4, sticky="ew")
|
||||
|
||||
# STATUS BAR
|
||||
status_frame = tk.Frame(root_frame, bg=BG_PANEL,
|
||||
highlightbackground=NEON_PURP, highlightthickness=2)
|
||||
status_frame.grid(row=3, column=0, sticky="ew", pady=(0, 8))
|
||||
status_frame.columnconfigure(1, weight=1)
|
||||
|
||||
self.lbl_status_dot = tk.Label(status_frame, text="●", font=("Courier", 14),
|
||||
fg=NEON_YELL, bg=BG_PANEL, padx=8)
|
||||
self.lbl_status_dot.grid(row=0, column=0)
|
||||
self.lbl_status = tk.Label(status_frame, text="IDLE", font=FONT_MONO_SM,
|
||||
fg=NEON_YELL, bg=BG_PANEL)
|
||||
self.lbl_status.grid(row=0, column=1, sticky="w")
|
||||
self.lbl_current = tk.Label(status_frame, text="", font=FONT_MONO_SM,
|
||||
fg=NEON_CYAN, bg=BG_PANEL, padx=8)
|
||||
self.lbl_current.grid(row=0, column=2, sticky="e")
|
||||
|
||||
# OUTPUT
|
||||
out_frame = tk.Frame(root_frame, bg=BG_PANEL,
|
||||
highlightbackground=NEON_PINK, highlightthickness=2)
|
||||
out_frame.grid(row=4, column=0, sticky="nsew", pady=(0, 8))
|
||||
out_frame.columnconfigure(0, weight=1)
|
||||
out_frame.rowconfigure(1, weight=1)
|
||||
root_frame.rowconfigure(4, weight=1)
|
||||
|
||||
out_hdr = tk.Frame(out_frame, bg=BG_PANEL)
|
||||
out_hdr.grid(row=0, column=0, sticky="ew", padx=8, pady=(6, 2))
|
||||
tk.Label(out_hdr, text="// WORK LOG OUTPUT", font=FONT_BIG,
|
||||
fg=NEON_PINK, bg=BG_PANEL).pack(side="left")
|
||||
|
||||
btn_area = tk.Frame(out_hdr, bg=BG_PANEL)
|
||||
btn_area.pack(side="right")
|
||||
|
||||
def small_btn(text, cmd, fg):
|
||||
return tk.Button(btn_area, text=text, command=cmd, font=FONT_MONO_SM,
|
||||
fg=fg, bg=BG_PANEL, relief="flat",
|
||||
highlightbackground=fg, highlightthickness=1,
|
||||
padx=6, pady=3, cursor="hand2")
|
||||
|
||||
small_btn("Copy", self._on_copy, NEON_CYAN).pack(side="left", padx=3)
|
||||
small_btn("Export .md", self._on_export, NEON_LIME).pack(side="left", padx=3)
|
||||
|
||||
self.txt_output = tk.Text(out_frame, font=FONT_MONO,
|
||||
bg=BG_ELEV, fg=TEXT_MAIN,
|
||||
insertbackground=NEON_CYAN,
|
||||
relief="flat", padx=10, pady=10,
|
||||
state="disabled", wrap="none",
|
||||
highlightthickness=0)
|
||||
self.txt_output.grid(row=1, column=0, sticky="nsew", padx=2, pady=2)
|
||||
|
||||
scroll_y = tk.Scrollbar(out_frame, command=self.txt_output.yview,
|
||||
bg=BG_DARK, troughcolor=BG_ELEV)
|
||||
scroll_y.grid(row=1, column=1, sticky="ns")
|
||||
scroll_x = tk.Scrollbar(out_frame, orient="horizontal",
|
||||
command=self.txt_output.xview,
|
||||
bg=BG_DARK, troughcolor=BG_ELEV)
|
||||
scroll_x.grid(row=2, column=0, sticky="ew")
|
||||
self.txt_output.configure(yscrollcommand=scroll_y.set,
|
||||
xscrollcommand=scroll_x.set)
|
||||
|
||||
# FOOTER
|
||||
tk.Label(root_frame,
|
||||
text="⚠ DATA IS LOST WHEN APP CLOSES — Export before quitting ⚠",
|
||||
font=FONT_MONO_SM, fg=NEON_YELL, bg=BG_DARK)\
|
||||
.grid(row=5, column=0, pady=(4, 0))
|
||||
|
||||
# ── HELPERS ────────────────────────────────────────────────────────────────
|
||||
def _ensure_today(self):
|
||||
key = today_key()
|
||||
for i, d in enumerate(self.days):
|
||||
if d["key"] == key:
|
||||
self.active_day_idx = i
|
||||
return False
|
||||
self.days.append({"key": key, "date": fmt_date(datetime.now()), "lines": []})
|
||||
self.active_day_idx = len(self.days) - 1
|
||||
return True
|
||||
|
||||
def _active_day(self) -> dict:
|
||||
return self.days[self.active_day_idx]
|
||||
|
||||
def _all_cats(self) -> list[str]:
|
||||
return DEFAULT_CATS + self.custom_cats
|
||||
|
||||
def _set_status(self, active: bool, task_text: str = ""):
|
||||
if active:
|
||||
self.lbl_status_dot.config(fg=NEON_LIME)
|
||||
self.lbl_status.config(text="TASK ACTIVE", fg=NEON_LIME)
|
||||
self.lbl_current.config(text=task_text)
|
||||
else:
|
||||
self.lbl_status_dot.config(fg=NEON_YELL)
|
||||
self.lbl_status.config(text="IDLE", fg=NEON_YELL)
|
||||
self.lbl_current.config(text="")
|
||||
|
||||
def _refresh_log(self):
|
||||
name = self.var_name.get().strip() or "Unknown"
|
||||
org = self.var_org.get().strip() or "Unknown"
|
||||
lines = []
|
||||
for idx, day in enumerate(self.days):
|
||||
if idx > 0:
|
||||
lines.append("\n---\n")
|
||||
lines.append(f"{day['date']} - {name} - {org}\n")
|
||||
lines.append("\n| BEGIN | TASK/ACTIVITY | END | HRS |\n")
|
||||
lines.append("| :-- | :-- | :-- | :-: |\n")
|
||||
for ln in day["lines"]:
|
||||
lines.append(f"| {ln['start']} | {ln['desc']} | {ln['end']} | {ln['hours']} |\n")
|
||||
if idx == self.active_day_idx and self.current_task:
|
||||
ct = self.current_task
|
||||
lines.append(f"| {ct['start_time']} | {ct['category']} - {ct['desc']} | ... | ... |\n")
|
||||
|
||||
content = "".join(lines)
|
||||
self.txt_output.config(state="normal")
|
||||
self.txt_output.delete("1.0", "end")
|
||||
self.txt_output.insert("1.0", content)
|
||||
self.txt_output.config(state="disabled")
|
||||
|
||||
def _get_log_text(self) -> str:
|
||||
return self.txt_output.get("1.0", "end-1c")
|
||||
|
||||
# ── ACTIONS ───────────────────────────────────────────────────────────────
|
||||
def _on_start(self):
|
||||
if not self.var_name.get().strip() or not self.var_org.get().strip():
|
||||
messagebox.showwarning("Missing info", "Please enter Name and Organization.")
|
||||
return
|
||||
self._ensure_today()
|
||||
StartTaskDialog(self)
|
||||
|
||||
def _on_end(self):
|
||||
if not self.current_task:
|
||||
messagebox.showinfo("No Task", "There is no active task to end.")
|
||||
return
|
||||
EndTaskDialog(self)
|
||||
|
||||
def _on_cats(self):
|
||||
CategoriesDialog(self)
|
||||
|
||||
def _on_new_day(self):
|
||||
if not self.var_name.get().strip() or not self.var_org.get().strip():
|
||||
messagebox.showwarning("Missing info", "Please enter Name and Organization.")
|
||||
return
|
||||
key = today_key()
|
||||
self.days.append({"key": key, "date": fmt_date(datetime.now()), "lines": []})
|
||||
self.active_day_idx = len(self.days) - 1
|
||||
self.current_task = None
|
||||
self._set_status(False)
|
||||
self._refresh_log()
|
||||
|
||||
def _on_copy(self):
|
||||
text = self._get_log_text()
|
||||
self.clipboard_clear()
|
||||
self.clipboard_append(text)
|
||||
messagebox.showinfo("Copied", "Worklog copied to clipboard.")
|
||||
|
||||
def _on_export(self):
|
||||
text = self._get_log_text().strip()
|
||||
if not text:
|
||||
messagebox.showwarning("Empty", "Nothing to export yet.")
|
||||
return
|
||||
name = (self.var_name.get().strip() or "worklog").replace(" ", "_")
|
||||
default_name = f"worklog_{name}_{today_key()}.md"
|
||||
path = filedialog.asksaveasfilename(
|
||||
defaultextension=".md",
|
||||
filetypes=[("Markdown files", "*.md"), ("Text files", "*.txt"), ("All files", "*.*")],
|
||||
initialfile=default_name,
|
||||
title="Export Worklog"
|
||||
)
|
||||
if path:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
messagebox.showinfo("Exported", f"Saved to:\n{path}")
|
||||
|
||||
|
||||
# ─── DIALOGS ──────────────────────────────────────────────────────────────────
|
||||
class BaseDialog(tk.Toplevel):
|
||||
def __init__(self, app: WorkdayLogger, title: str, border_color: str = NEON_PINK):
|
||||
super().__init__(app)
|
||||
self.app = app
|
||||
self.title(title)
|
||||
self.configure(bg=BG_PANEL)
|
||||
self.resizable(False, False)
|
||||
self.grab_set()
|
||||
# Border effect via highlight
|
||||
self.config(highlightbackground=border_color, highlightthickness=3)
|
||||
self.bind("<Escape>", lambda e: self.destroy())
|
||||
self._center()
|
||||
|
||||
def _center(self):
|
||||
self.update_idletasks()
|
||||
w, h = self.winfo_reqwidth(), self.winfo_reqheight()
|
||||
x = self.app.winfo_x() + (self.app.winfo_width() - w) // 2
|
||||
y = self.app.winfo_y() + (self.app.winfo_height() - h) // 2
|
||||
self.geometry(f"+{x}+{y}")
|
||||
|
||||
|
||||
class StartTaskDialog(BaseDialog):
|
||||
def __init__(self, app: WorkdayLogger):
|
||||
super().__init__(app, "> START NEW TASK <")
|
||||
self.minsize(380, 260)
|
||||
|
||||
frame = tk.Frame(self, bg=BG_PANEL, padx=20, pady=16)
|
||||
frame.pack(fill="both", expand=True)
|
||||
|
||||
tk.Label(frame, text="> START NEW TASK <", font=("Courier", 14, "bold"),
|
||||
fg=NEON_PINK, bg=BG_PANEL).pack(pady=(0, 12))
|
||||
|
||||
# Category
|
||||
tk.Label(frame, text="CATEGORY:", font=FONT_MONO_SM, fg=NEON_CYAN, bg=BG_PANEL,
|
||||
anchor="w").pack(fill="x")
|
||||
self.var_cat = tk.StringVar()
|
||||
cats = app._all_cats()
|
||||
cat_cb = ttk.Combobox(frame, textvariable=self.var_cat, values=cats,
|
||||
font=FONT_MONO, state="readonly", width=40)
|
||||
cat_cb.pack(fill="x", pady=(2, 10))
|
||||
self._style_combo(cat_cb)
|
||||
|
||||
# Description
|
||||
tk.Label(frame, text="TASK DESCRIPTION:", font=FONT_MONO_SM, fg=NEON_CYAN, bg=BG_PANEL,
|
||||
anchor="w").pack(fill="x")
|
||||
self.var_desc = tk.StringVar()
|
||||
tk.Entry(frame, textvariable=self.var_desc, font=FONT_MONO,
|
||||
bg=BG_ELEV, fg="white", insertbackground="white",
|
||||
relief="flat", highlightbackground=NEON_CYAN,
|
||||
highlightthickness=1).pack(fill="x", pady=(2, 14))
|
||||
|
||||
# Buttons
|
||||
btn_row = tk.Frame(frame, bg=BG_PANEL)
|
||||
btn_row.pack(fill="x")
|
||||
btn_row.columnconfigure(0, weight=1)
|
||||
btn_row.columnconfigure(1, weight=1)
|
||||
|
||||
tk.Button(btn_row, text="> COMMIT <", command=self._commit,
|
||||
font=("Courier", 12, "bold"), fg=NEON_CYAN, bg=BG_PANEL,
|
||||
relief="flat", highlightbackground=NEON_CYAN, highlightthickness=2,
|
||||
pady=6, cursor="hand2").grid(row=0, column=0, padx=(0,4), sticky="ew")
|
||||
tk.Button(btn_row, text="> CANCEL <", command=self.destroy,
|
||||
font=("Courier", 12, "bold"), fg=TEXT_DIM, bg=BG_PANEL,
|
||||
relief="flat", highlightbackground=TEXT_DIM, highlightthickness=2,
|
||||
pady=6, cursor="hand2").grid(row=0, column=1, padx=(4,0), sticky="ew")
|
||||
|
||||
cat_cb.focus_set()
|
||||
self._center()
|
||||
|
||||
def _style_combo(self, cb):
|
||||
style = ttk.Style()
|
||||
style.theme_use("default")
|
||||
style.configure("TCombobox",
|
||||
fieldbackground=BG_ELEV, background=BG_ELEV,
|
||||
foreground="white", selectforeground="white",
|
||||
selectbackground=BG_ELEV)
|
||||
|
||||
def _commit(self):
|
||||
cat = self.var_cat.get().strip()
|
||||
desc = self.var_desc.get().strip()
|
||||
if not cat: messagebox.showwarning("Missing", "Please select a category.", parent=self); return
|
||||
if not desc: messagebox.showwarning("Missing", "Please enter a task description.", parent=self); return
|
||||
|
||||
now = datetime.now()
|
||||
day = self.app._active_day()
|
||||
|
||||
if self.app.current_task:
|
||||
ct = self.app.current_task
|
||||
hrs = calc_hours(ct["start_dt"], now)
|
||||
day["lines"].append({
|
||||
"start": ct["start_time"],
|
||||
"desc": f"{ct['category']} - {ct['desc']} (switched at {fmt_time(now)})",
|
||||
"end": fmt_time(now),
|
||||
"hours": hrs
|
||||
})
|
||||
|
||||
self.app.current_task = {
|
||||
"start_dt": now,
|
||||
"start_time": fmt_time(now),
|
||||
"category": cat,
|
||||
"desc": desc
|
||||
}
|
||||
self.app._set_status(True, f"{cat} - {desc}")
|
||||
self.app._refresh_log()
|
||||
self.destroy()
|
||||
|
||||
|
||||
class EndTaskDialog(BaseDialog):
|
||||
def __init__(self, app: WorkdayLogger):
|
||||
super().__init__(app, "> END TASK <")
|
||||
ct = app.current_task
|
||||
self.minsize(340, 260)
|
||||
|
||||
frame = tk.Frame(self, bg=BG_PANEL, padx=20, pady=16)
|
||||
frame.pack(fill="both", expand=True)
|
||||
|
||||
tk.Label(frame, text="> END TASK <", font=("Courier", 14, "bold"),
|
||||
fg=NEON_PINK, bg=BG_PANEL).pack(pady=(0, 10))
|
||||
|
||||
now = datetime.now()
|
||||
hrs = calc_hours(ct["start_dt"], now)
|
||||
|
||||
def info_row(label, value, color):
|
||||
row = tk.Frame(frame, bg=BG_PANEL)
|
||||
row.pack(fill="x", pady=2)
|
||||
tk.Label(row, text=f"{label}:", font=FONT_MONO_SM, fg=NEON_CYAN, bg=BG_PANEL,
|
||||
width=14, anchor="w").pack(side="left")
|
||||
tk.Label(row, text=value, font=FONT_MONO, fg=color, bg=BG_PANEL,
|
||||
anchor="w").pack(side="left")
|
||||
|
||||
info_row("TASK", f"{ct['category']} - {ct['desc']}", NEON_CYAN)
|
||||
info_row("STARTED", ct["start_time"], NEON_LIME)
|
||||
info_row("ENDS", fmt_time(now), NEON_RED)
|
||||
info_row("DURATION", f"{hrs} hours (to 0.5h)", NEON_YELL)
|
||||
|
||||
tk.Frame(frame, bg=NEON_PINK, height=1).pack(fill="x", pady=10)
|
||||
|
||||
btn_row = tk.Frame(frame, bg=BG_PANEL)
|
||||
btn_row.pack(fill="x")
|
||||
btn_row.columnconfigure(0, weight=1)
|
||||
btn_row.columnconfigure(1, weight=1)
|
||||
|
||||
tk.Button(btn_row, text="> COMMIT <", command=self._commit,
|
||||
font=("Courier", 12, "bold"), fg=NEON_CYAN, bg=BG_PANEL,
|
||||
relief="flat", highlightbackground=NEON_CYAN, highlightthickness=2,
|
||||
pady=6, cursor="hand2").grid(row=0, column=0, padx=(0,4), sticky="ew")
|
||||
tk.Button(btn_row, text="> CANCEL <", command=self.destroy,
|
||||
font=("Courier", 12, "bold"), fg=TEXT_DIM, bg=BG_PANEL,
|
||||
relief="flat", highlightbackground=TEXT_DIM, highlightthickness=2,
|
||||
pady=6, cursor="hand2").grid(row=0, column=1, padx=(4,0), sticky="ew")
|
||||
|
||||
self._now = now
|
||||
self._hours = hrs
|
||||
self._center()
|
||||
|
||||
def _commit(self):
|
||||
ct = self.app.current_task
|
||||
self.app._active_day()["lines"].append({
|
||||
"start": ct["start_time"],
|
||||
"desc": f"{ct['category']} - {ct['desc']}",
|
||||
"end": fmt_time(self._now),
|
||||
"hours": self._hours
|
||||
})
|
||||
self.app.current_task = None
|
||||
self.app._set_status(False)
|
||||
self.app._refresh_log()
|
||||
self.destroy()
|
||||
|
||||
|
||||
class CategoriesDialog(BaseDialog):
|
||||
def __init__(self, app: WorkdayLogger):
|
||||
super().__init__(app, "> CATEGORIES <", border_color=NEON_ORNG)
|
||||
self.minsize(360, 420)
|
||||
|
||||
frame = tk.Frame(self, bg=BG_PANEL, padx=16, pady=14)
|
||||
frame.pack(fill="both", expand=True)
|
||||
frame.rowconfigure(2, weight=1)
|
||||
frame.columnconfigure(0, weight=1)
|
||||
|
||||
tk.Label(frame, text="> MANAGE CATEGORIES <", font=("Courier", 13, "bold"),
|
||||
fg=NEON_ORNG, bg=BG_PANEL).grid(row=0, column=0, columnspan=2, pady=(0,10))
|
||||
|
||||
# Add row
|
||||
add_frame = tk.Frame(frame, bg=BG_PANEL)
|
||||
add_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 8))
|
||||
add_frame.columnconfigure(0, weight=1)
|
||||
|
||||
self.var_new = tk.StringVar()
|
||||
tk.Entry(add_frame, textvariable=self.var_new, font=FONT_MONO,
|
||||
bg=BG_ELEV, fg="white", insertbackground="white",
|
||||
relief="flat", highlightbackground=NEON_ORNG,
|
||||
highlightthickness=1, width=24).grid(row=0, column=0, sticky="ew", padx=(0,6))
|
||||
tk.Button(add_frame, text="+ ADD", command=self._add,
|
||||
font=FONT_MONO_SM, fg=NEON_ORNG, bg=BG_PANEL,
|
||||
relief="flat", highlightbackground=NEON_ORNG, highlightthickness=1,
|
||||
padx=8, pady=4, cursor="hand2").grid(row=0, column=1)
|
||||
|
||||
# List
|
||||
listbox_frame = tk.Frame(frame, bg=BG_ELEV,
|
||||
highlightbackground=NEON_ORNG, highlightthickness=1)
|
||||
listbox_frame.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(0, 8))
|
||||
listbox_frame.rowconfigure(0, weight=1)
|
||||
listbox_frame.columnconfigure(0, weight=1)
|
||||
|
||||
self.listbox = tk.Listbox(listbox_frame, font=FONT_MONO_SM,
|
||||
bg=BG_ELEV, fg=TEXT_MAIN,
|
||||
selectbackground=BG_PANEL,
|
||||
selectforeground=NEON_ORNG,
|
||||
relief="flat", highlightthickness=0,
|
||||
activestyle="none", height=14)
|
||||
self.listbox.grid(row=0, column=0, sticky="nsew")
|
||||
sb = tk.Scrollbar(listbox_frame, command=self.listbox.yview, bg=BG_DARK)
|
||||
sb.grid(row=0, column=1, sticky="ns")
|
||||
self.listbox.config(yscrollcommand=sb.set)
|
||||
|
||||
self._populate_list()
|
||||
|
||||
tk.Button(frame, text="[ DELETE SELECTED CUSTOM CAT ]", command=self._delete,
|
||||
font=FONT_MONO_SM, fg=NEON_RED, bg=BG_PANEL,
|
||||
relief="flat", highlightbackground=NEON_RED, highlightthickness=1,
|
||||
pady=4, cursor="hand2").grid(row=3, column=0, columnspan=2, sticky="ew", pady=(0,6))
|
||||
|
||||
tk.Label(frame, text="⚠ Custom categories are session-only",
|
||||
font=FONT_MONO_SM, fg=TEXT_DIM, bg=BG_PANEL)\
|
||||
.grid(row=4, column=0, columnspan=2)
|
||||
|
||||
tk.Button(frame, text="> CLOSE <", command=self.destroy,
|
||||
font=("Courier", 12, "bold"), fg=TEXT_DIM, bg=BG_PANEL,
|
||||
relief="flat", highlightbackground=TEXT_DIM, highlightthickness=2,
|
||||
pady=6, cursor="hand2").grid(row=5, column=0, columnspan=2, sticky="ew", pady=(8,0))
|
||||
|
||||
self.var_new.trace_add("write", lambda *_: None)
|
||||
self.bind("<Return>", lambda e: self._add())
|
||||
self._center()
|
||||
|
||||
def _populate_list(self):
|
||||
self.listbox.delete(0, "end")
|
||||
for c in DEFAULT_CATS:
|
||||
self.listbox.insert("end", f" {c} [built-in]")
|
||||
for c in self.app.custom_cats:
|
||||
self.listbox.insert("end", f" {c} [custom]")
|
||||
idx = self.listbox.size() - 1
|
||||
self.listbox.itemconfig(idx, fg=NEON_ORNG)
|
||||
|
||||
def _add(self):
|
||||
val = self.var_new.get().strip().replace(" ", "-")
|
||||
if not val:
|
||||
return
|
||||
all_cats = DEFAULT_CATS + self.app.custom_cats
|
||||
if val in all_cats:
|
||||
self.var_new.set("")
|
||||
return
|
||||
self.app.custom_cats.append(val)
|
||||
self.var_new.set("")
|
||||
self._populate_list()
|
||||
|
||||
def _delete(self):
|
||||
sel = self.listbox.curselection()
|
||||
if not sel:
|
||||
return
|
||||
idx = sel[0]
|
||||
n_builtin = len(DEFAULT_CATS)
|
||||
if idx < n_builtin:
|
||||
messagebox.showinfo("Built-in", "Built-in categories cannot be deleted.", parent=self)
|
||||
return
|
||||
custom_idx = idx - n_builtin
|
||||
del self.app.custom_cats[custom_idx]
|
||||
self._populate_list()
|
||||
|
||||
|
||||
# ─── ENTRY POINT ─────────────────────────────────────────────────────────────
|
||||
if __name__ == "__main__":
|
||||
app = WorkdayLogger()
|
||||
app.mainloop()
|
||||
728
RememberToForget_WorkdayLogger_v2.html
Normal file
728
RememberToForget_WorkdayLogger_v2.html
Normal file
@@ -0,0 +1,728 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>REMEMBER TO FORGET // WORKLOG</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=VT323&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* ===== PUNK RETROFUTURIST STYLE ===== */
|
||||
:root {
|
||||
--bg-dark: #0a0a0f;
|
||||
--bg-panel: #12121a;
|
||||
--bg-elevated: #1a1a25;
|
||||
--neon-pink: #ff00ff;
|
||||
--neon-cyan: #00ffff;
|
||||
--neon-lime: #39ff14;
|
||||
--neon-yellow: #ffff00;
|
||||
--neon-red: #ff0033;
|
||||
--neon-purple: #bf00ff;
|
||||
--neon-orange: #ff8800;
|
||||
--pink-glow: rgba(255, 0, 255, 0.4);
|
||||
--cyan-glow: rgba(0, 255, 255, 0.4);
|
||||
--lime-glow: rgba(57, 255, 20, 0.4);
|
||||
--text-primary: #e0e0e0;
|
||||
--text-dim: #888899;
|
||||
--text-bright: #ffffff;
|
||||
--border-width: 2px;
|
||||
--border-width-thick: 4px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: repeating-linear-gradient(0deg, rgba(255,0,255,0.02) 0px, transparent 1px, transparent 2px, rgba(0,255,255,0.02) 3px, transparent 4px);
|
||||
pointer-events: none; z-index: 9999; opacity: 0.6;
|
||||
}
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.12) 0px, transparent 1px, transparent 2px);
|
||||
pointer-events: none; z-index: 9998;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.container { max-width: 900px; margin: 0 auto; position: relative; z-index: 1; }
|
||||
|
||||
/* ===== HEADER ===== */
|
||||
header {
|
||||
text-align: center; margin-bottom: 2rem; padding: 1.5rem;
|
||||
border: var(--border-width) solid var(--neon-pink);
|
||||
background: var(--bg-panel);
|
||||
box-shadow: 0 0 20px var(--pink-glow), inset 0 0 20px rgba(255,0,255,0.05);
|
||||
}
|
||||
h1 {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: clamp(2.5rem, 8vw, 4rem);
|
||||
color: var(--neon-pink);
|
||||
text-shadow: 0 0 10px var(--neon-pink), 0 0 20px var(--neon-pink), 0 0 40px var(--neon-pink);
|
||||
letter-spacing: 0.15em; text-transform: uppercase;
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--neon-cyan); font-size: 0.9rem;
|
||||
letter-spacing: 0.3em; text-shadow: 0 0 10px var(--neon-cyan); margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* ===== INPUT SECTION ===== */
|
||||
.input-section {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem;
|
||||
}
|
||||
@media (max-width: 600px) { .input-section { grid-template-columns: 1fr; } }
|
||||
|
||||
.input-group { display: flex; flex-direction: column; }
|
||||
.input-group label {
|
||||
color: var(--neon-cyan); font-size: 0.8rem;
|
||||
text-transform: uppercase; letter-spacing: 0.1em;
|
||||
margin-bottom: 0.5rem; text-shadow: 0 0 5px var(--neon-cyan);
|
||||
}
|
||||
.input-group input {
|
||||
background: var(--bg-elevated); border: var(--border-width) solid var(--neon-cyan);
|
||||
color: var(--text-bright); padding: 0.75rem 1rem;
|
||||
font-family: 'Share Tech Mono', monospace; font-size: 1rem;
|
||||
outline: none; transition: all 0.3s;
|
||||
}
|
||||
.input-group input:focus { box-shadow: 0 0 15px var(--cyan-glow); border-color: var(--neon-pink); }
|
||||
|
||||
/* ===== BUTTON SECTION ===== */
|
||||
.button-section {
|
||||
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem;
|
||||
}
|
||||
@media (max-width: 600px) { .button-section { grid-template-columns: 1fr; } }
|
||||
|
||||
.btn {
|
||||
padding: 1rem 2rem; font-family: 'VT323', monospace; font-size: 1.5rem;
|
||||
text-transform: uppercase; letter-spacing: 0.15em;
|
||||
border: var(--border-width-thick) solid; cursor: pointer;
|
||||
transition: all 0.2s; position: relative; overflow: hidden;
|
||||
}
|
||||
.btn::before { content: '> '; }
|
||||
.btn::after { content: ' <'; }
|
||||
|
||||
.btn-start {
|
||||
background: linear-gradient(135deg, rgba(57,255,20,0.15), rgba(57,255,20,0.05));
|
||||
border-color: var(--neon-lime); color: var(--neon-lime);
|
||||
text-shadow: 0 0 10px var(--neon-lime); box-shadow: 0 0 20px var(--lime-glow);
|
||||
}
|
||||
.btn-start:hover { background: rgba(57,255,20,0.25); box-shadow: 0 0 30px var(--neon-lime), 0 0 50px var(--lime-glow); transform: translateY(-2px); }
|
||||
|
||||
.btn-end {
|
||||
background: linear-gradient(135deg, rgba(255,0,51,0.15), rgba(255,0,51,0.05));
|
||||
border-color: var(--neon-red); color: var(--neon-red);
|
||||
text-shadow: 0 0 10px var(--neon-red); box-shadow: 0 0 20px rgba(255,0,51,0.4);
|
||||
}
|
||||
.btn-end:hover { background: rgba(255,0,51,0.25); box-shadow: 0 0 30px var(--neon-red), 0 0 50px rgba(255,0,51,0.4); transform: translateY(-2px); }
|
||||
|
||||
.btn-cats {
|
||||
background: linear-gradient(135deg, rgba(255,136,0,0.15), rgba(255,136,0,0.05));
|
||||
border-color: var(--neon-orange); color: var(--neon-orange);
|
||||
text-shadow: 0 0 10px var(--neon-orange); box-shadow: 0 0 20px rgba(255,136,0,0.4);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.btn-cats:hover { background: rgba(255,136,0,0.25); box-shadow: 0 0 30px var(--neon-orange); transform: translateY(-2px); }
|
||||
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; }
|
||||
|
||||
/* ===== STATUS BAR ===== */
|
||||
.status-bar {
|
||||
background: var(--bg-panel); border: var(--border-width) solid var(--neon-purple);
|
||||
padding: 0.75rem 1rem; margin-bottom: 1.5rem;
|
||||
display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem;
|
||||
}
|
||||
.status-indicator { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.status-dot { width: 10px; height: 10px; border-radius: 50%; animation: pulse 1.5s infinite; }
|
||||
.status-dot.active { background: var(--neon-lime); box-shadow: 0 0 10px var(--neon-lime); }
|
||||
.status-dot.idle { background: var(--neon-yellow); box-shadow: 0 0 10px var(--neon-yellow); animation: none; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
.current-task { color: var(--neon-cyan); }
|
||||
|
||||
/* ===== OUTPUT SECTION ===== */
|
||||
.output-section { background: var(--bg-panel); border: var(--border-width) solid var(--neon-pink); padding: 1rem; }
|
||||
.output-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.output-header h2 { font-family: 'VT323', monospace; font-size: 1.25rem; color: var(--neon-pink); text-shadow: 0 0 10px var(--neon-pink); letter-spacing: 0.1em; }
|
||||
.output-btns { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
|
||||
.btn-copy, .btn-export, .btn-newday {
|
||||
background: transparent; border: var(--border-width) solid;
|
||||
padding: 0.5rem 1rem; font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 0.8rem; cursor: pointer; transition: all 0.2s; text-transform: uppercase;
|
||||
}
|
||||
.btn-copy { border-color: var(--neon-cyan); color: var(--neon-cyan); }
|
||||
.btn-copy:hover { background: rgba(0,255,255,0.15); box-shadow: 0 0 15px var(--cyan-glow); }
|
||||
.btn-copy.copied { border-color: var(--neon-lime); color: var(--neon-lime); box-shadow: 0 0 15px var(--lime-glow); }
|
||||
|
||||
.btn-export { border-color: var(--neon-lime); color: var(--neon-lime); }
|
||||
.btn-export:hover { background: rgba(57,255,20,0.15); box-shadow: 0 0 15px var(--lime-glow); }
|
||||
|
||||
.btn-newday { border-color: var(--neon-yellow); color: var(--neon-yellow); }
|
||||
.btn-newday:hover { background: rgba(255,255,0,0.1); box-shadow: 0 0 15px rgba(255,255,0,0.4); }
|
||||
|
||||
#worklog-output {
|
||||
width: 100%; min-height: 300px;
|
||||
background: var(--bg-elevated); border: var(--border-width) solid var(--neon-cyan);
|
||||
color: var(--text-primary); padding: 1rem;
|
||||
font-family: 'Share Tech Mono', monospace; font-size: 0.9rem;
|
||||
line-height: 1.6; resize: vertical; outline: none;
|
||||
cursor: default;
|
||||
}
|
||||
#worklog-output[readonly] { opacity: 0.9; }
|
||||
|
||||
/* ===== LIGHTBOX / MODAL ===== */
|
||||
.lightbox-overlay {
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(10,10,15,0.95); display: flex;
|
||||
justify-content: center; align-items: center;
|
||||
z-index: 10000; opacity: 0; visibility: hidden; transition: all 0.3s;
|
||||
}
|
||||
.lightbox-overlay.active { opacity: 1; visibility: visible; }
|
||||
.lightbox {
|
||||
background: var(--bg-panel); border: var(--border-width-thick) solid var(--neon-pink);
|
||||
box-shadow: 0 0 40px var(--pink-glow), 0 0 80px rgba(255,0,255,0.2);
|
||||
padding: 2rem; max-width: 500px; width: 90%;
|
||||
position: relative; transform: scale(0.95); transition: transform 0.3s;
|
||||
}
|
||||
.lightbox-overlay.active .lightbox { transform: scale(1); }
|
||||
.lightbox-header {
|
||||
text-align: center; margin-bottom: 1.5rem; padding-bottom: 1rem;
|
||||
border-bottom: var(--border-width) solid var(--neon-cyan);
|
||||
}
|
||||
.lightbox-header h2 { font-family: 'VT323', monospace; font-size: 1.75rem; color: var(--neon-pink); text-shadow: 0 0 15px var(--neon-pink); letter-spacing: 0.1em; }
|
||||
.lightbox-body { margin-bottom: 1.5rem; }
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label { display: block; color: var(--neon-cyan); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.5rem; }
|
||||
.form-group select, .form-group input[type="text"] {
|
||||
width: 100%; background: var(--bg-elevated); border: var(--border-width) solid var(--neon-cyan);
|
||||
color: var(--text-bright); padding: 0.75rem 1rem;
|
||||
font-family: 'Share Tech Mono', monospace; font-size: 1rem; outline: none;
|
||||
}
|
||||
.form-group select option { background: var(--bg-elevated); }
|
||||
.form-group select:focus, .form-group input[type="text"]:focus { box-shadow: 0 0 15px var(--cyan-glow); border-color: var(--neon-pink); }
|
||||
|
||||
.lightbox-footer { display: flex; gap: 1rem; }
|
||||
.lightbox-footer .btn { flex: 1; }
|
||||
|
||||
.btn-commit {
|
||||
background: linear-gradient(135deg, rgba(0,255,255,0.15), rgba(0,255,255,0.05));
|
||||
border-color: var(--neon-cyan); color: var(--neon-cyan); text-shadow: 0 0 10px var(--neon-cyan);
|
||||
}
|
||||
.btn-commit:hover { background: rgba(0,255,255,0.25); box-shadow: 0 0 20px var(--neon-cyan); }
|
||||
.btn-cancel { background: transparent; border-color: var(--text-dim); color: var(--text-dim); }
|
||||
.btn-cancel:hover { border-color: var(--neon-red); color: var(--neon-red); }
|
||||
|
||||
.alert-message {
|
||||
background: rgba(255,0,51,0.1); border: var(--border-width) solid var(--neon-red);
|
||||
padding: 1rem; color: var(--neon-red); text-align: center; margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ===== CATEGORIES LIGHTBOX ===== */
|
||||
.lightbox-cats { border-color: var(--neon-orange); box-shadow: 0 0 40px rgba(255,136,0,0.4), 0 0 80px rgba(255,136,0,0.2); }
|
||||
.lightbox-cats .lightbox-header { border-bottom-color: var(--neon-orange); }
|
||||
.lightbox-cats .lightbox-header h2 { color: var(--neon-orange); text-shadow: 0 0 15px var(--neon-orange); }
|
||||
.cat-add-row { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.cat-add-row input {
|
||||
flex: 1; background: var(--bg-elevated); border: var(--border-width) solid var(--neon-orange);
|
||||
color: var(--text-bright); padding: 0.6rem 0.8rem;
|
||||
font-family: 'Share Tech Mono', monospace; font-size: 0.9rem; outline: none;
|
||||
}
|
||||
.cat-add-row input:focus { box-shadow: 0 0 10px rgba(255,136,0,0.4); }
|
||||
.btn-add-cat {
|
||||
background: rgba(255,136,0,0.15); border: var(--border-width) solid var(--neon-orange);
|
||||
color: var(--neon-orange); padding: 0.6rem 1rem;
|
||||
font-family: 'Share Tech Mono', monospace; font-size: 0.85rem;
|
||||
cursor: pointer; transition: all 0.2s; text-transform: uppercase;
|
||||
}
|
||||
.btn-add-cat:hover { background: rgba(255,136,0,0.3); box-shadow: 0 0 10px rgba(255,136,0,0.4); }
|
||||
|
||||
.cat-list { max-height: 250px; overflow-y: auto; border: 1px solid var(--neon-orange); background: var(--bg-elevated); }
|
||||
.cat-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0.5rem 0.75rem; border-bottom: 1px solid rgba(255,136,0,0.2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.cat-item:last-child { border-bottom: none; }
|
||||
.cat-item.builtin { color: var(--text-dim); }
|
||||
.cat-item.custom { color: var(--neon-orange); }
|
||||
.cat-delete {
|
||||
background: none; border: none; color: var(--neon-red);
|
||||
cursor: pointer; font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 0.8rem; padding: 0.2rem 0.4rem; transition: all 0.2s;
|
||||
}
|
||||
.cat-delete:hover { background: rgba(255,0,51,0.15); }
|
||||
.cat-notice { font-size: 0.75rem; color: var(--text-dim); margin-top: 0.75rem; text-align: center; }
|
||||
|
||||
/* Footer */
|
||||
footer { text-align: center; margin-top: 2rem; padding: 1rem; color: var(--text-dim); font-size: 0.75rem; letter-spacing: 0.1em; }
|
||||
.forgetful-notice { color: var(--neon-yellow); text-shadow: 0 0 5px var(--neon-yellow); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>WORKDAY LOGBOOK</h1>
|
||||
<div class="subtitle">FORGETFUL TASK TRACKER // NO DATA STORED</div>
|
||||
</header>
|
||||
|
||||
<section class="input-section">
|
||||
<div class="input-group">
|
||||
<label for="input-name">Operator Name</label>
|
||||
<input type="text" id="input-name" placeholder="ENTER NAME">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="input-org">Organization</label>
|
||||
<input type="text" id="input-org" placeholder="ENTER ORG">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="button-section">
|
||||
<button class="btn btn-start" id="btn-start">Start Task</button>
|
||||
<button class="btn btn-end" id="btn-end">End Task</button>
|
||||
<button class="btn btn-cats" id="btn-cats">Categories</button>
|
||||
</section>
|
||||
|
||||
<section class="status-bar">
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot idle" id="status-dot"></span>
|
||||
<span id="status-text">IDLE</span>
|
||||
</div>
|
||||
<div class="current-task" id="current-task"></div>
|
||||
</section>
|
||||
|
||||
<section class="output-section">
|
||||
<div class="output-header">
|
||||
<h2>// WORK LOG OUTPUT</h2>
|
||||
<div class="output-btns">
|
||||
<button class="btn-newday" id="btn-newday">+ New Day</button>
|
||||
<button class="btn-copy" id="btn-copy">Copy</button>
|
||||
<button class="btn-export" id="btn-export">Export .md</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="worklog-output" readonly placeholder="Work log will appear here..."></textarea>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p class="forgetful-notice">⚠ DATA IS LOST WHEN TAB CLOSES ⚠</p>
|
||||
<p>Copy or export your worklog before closing this page</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Start Task Lightbox -->
|
||||
<div class="lightbox-overlay" id="start-lightbox">
|
||||
<div class="lightbox">
|
||||
<div class="lightbox-header"><h2>> START NEW TASK</h2></div>
|
||||
<div class="lightbox-body">
|
||||
<div class="form-group">
|
||||
<label for="task-category">Category</label>
|
||||
<select id="task-category">
|
||||
<option value="">-- SELECT CATEGORY --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="task-description">Task Description</label>
|
||||
<input type="text" id="task-description" placeholder="Describe the task...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="lightbox-footer">
|
||||
<button class="btn btn-commit" id="btn-commit-start">Commit</button>
|
||||
<button class="btn btn-cancel" id="btn-cancel-start">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- End Task Lightbox -->
|
||||
<div class="lightbox-overlay" id="end-lightbox">
|
||||
<div class="lightbox">
|
||||
<div class="lightbox-header"><h2>> END TASK</h2></div>
|
||||
<div class="lightbox-body" id="end-lightbox-body"></div>
|
||||
<div class="lightbox-footer" id="end-lightbox-footer">
|
||||
<button class="btn btn-commit" id="btn-commit-end">Commit</button>
|
||||
<button class="btn btn-cancel" id="btn-cancel-end">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories Lightbox -->
|
||||
<div class="lightbox-overlay" id="cats-lightbox">
|
||||
<div class="lightbox lightbox-cats">
|
||||
<div class="lightbox-header"><h2>> CATEGORIES</h2></div>
|
||||
<div class="lightbox-body">
|
||||
<div class="cat-add-row">
|
||||
<input type="text" id="new-cat-input" placeholder="NEW-Category">
|
||||
<button class="btn-add-cat" id="btn-add-cat">+ Add</button>
|
||||
</div>
|
||||
<div class="cat-list" id="cat-list"></div>
|
||||
<p class="cat-notice">⚠ Custom categories are session-only — lost when tab closes</p>
|
||||
</div>
|
||||
<div class="lightbox-footer">
|
||||
<button class="btn btn-cancel" id="btn-close-cats">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===== STATE =====
|
||||
let currentTask = null;
|
||||
let days = []; // Array of { date: string, lines: [] }
|
||||
let activeDayIdx = 0;
|
||||
|
||||
// ===== BUILT-IN CATEGORIES =====
|
||||
const builtinCategories = [
|
||||
'EXO-Admin','EXO-Server','SOC-DevOps','SOC-Deployments','SOC-Collabs',
|
||||
'GEN-AdHoc','PMD-Setworks','THS-Admin','THS-Make',
|
||||
'HGH-Dev','HGH-PreProd','HGH-Prod','HGH-ShowTime','HGH-Post','HGH-Release',
|
||||
'TBE-Gaia','TBE-MadSkills','JL-General','JL-Creative',
|
||||
'3SC-Admin','3SC-DevOps','3SC-Deployments','3SC-Make','3SC-Public',
|
||||
'UnCat'
|
||||
];
|
||||
let customCategories = []; // session-only
|
||||
|
||||
// ===== DOM ELEMENTS =====
|
||||
const inputName = document.getElementById('input-name');
|
||||
const inputOrg = document.getElementById('input-org');
|
||||
const btnStart = document.getElementById('btn-start');
|
||||
const btnEnd = document.getElementById('btn-end');
|
||||
const btnCats = document.getElementById('btn-cats');
|
||||
const btnCopy = document.getElementById('btn-copy');
|
||||
const btnExport = document.getElementById('btn-export');
|
||||
const btnNewDay = document.getElementById('btn-newday');
|
||||
const worklogOutput = document.getElementById('worklog-output');
|
||||
const statusDot = document.getElementById('status-dot');
|
||||
const statusText = document.getElementById('status-text');
|
||||
const currentTaskDisp = document.getElementById('current-task');
|
||||
const startLightbox = document.getElementById('start-lightbox');
|
||||
const endLightbox = document.getElementById('end-lightbox');
|
||||
const catsLightbox = document.getElementById('cats-lightbox');
|
||||
const taskCategory = document.getElementById('task-category');
|
||||
const taskDescription = document.getElementById('task-description');
|
||||
const btnCommitStart = document.getElementById('btn-commit-start');
|
||||
const btnCancelStart = document.getElementById('btn-cancel-start');
|
||||
const endLightboxBody = document.getElementById('end-lightbox-body');
|
||||
const btnCommitEnd = document.getElementById('btn-commit-end');
|
||||
const btnCancelEnd = document.getElementById('btn-cancel-end');
|
||||
const catList = document.getElementById('cat-list');
|
||||
const newCatInput = document.getElementById('new-cat-input');
|
||||
const btnAddCat = document.getElementById('btn-add-cat');
|
||||
const btnCloseCats = document.getElementById('btn-close-cats');
|
||||
|
||||
// ===== UTILITIES =====
|
||||
function formatDate(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth()+1).padStart(2,'0');
|
||||
const d = String(date.getDate()).padStart(2,'0');
|
||||
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||
return `${y}-${m}-${d} - ${days[date.getDay()]}`;
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
return `${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function todayKey() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function calculateDuration(startDate, endDate) {
|
||||
const diffHours = (endDate - startDate) / (1000 * 60 * 60);
|
||||
return Math.round(diffHours * 2) / 2;
|
||||
}
|
||||
|
||||
function updateStatus(active, taskDesc = '') {
|
||||
if (active) {
|
||||
statusDot.classList.replace('idle', 'active');
|
||||
statusText.textContent = 'TASK ACTIVE';
|
||||
currentTaskDisp.textContent = taskDesc;
|
||||
} else {
|
||||
statusDot.classList.replace('active', 'idle');
|
||||
statusText.textContent = 'IDLE';
|
||||
currentTaskDisp.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== DAY MANAGEMENT =====
|
||||
function ensureTodayExists() {
|
||||
const key = todayKey();
|
||||
const existing = days.findIndex(d => d.key === key);
|
||||
if (existing === -1) {
|
||||
days.push({ key, date: formatDate(new Date()), lines: [] });
|
||||
activeDayIdx = days.length - 1;
|
||||
return true; // was new
|
||||
}
|
||||
activeDayIdx = existing;
|
||||
return false;
|
||||
}
|
||||
|
||||
function getActiveDay() {
|
||||
return days[activeDayIdx];
|
||||
}
|
||||
|
||||
// ===== WORKLOG GENERATION =====
|
||||
function generateWorklog() {
|
||||
const name = inputName.value.trim() || 'Unknown';
|
||||
const org = inputOrg.value.trim() || 'Unknown';
|
||||
|
||||
let output = '';
|
||||
days.forEach((day, idx) => {
|
||||
if (idx > 0) output += '\n\n---\n\n';
|
||||
output += `${day.date} - ${name} - ${org}\n\n`;
|
||||
output += '| BEGIN | TASK/ACTIVITY | END | HRS |\n';
|
||||
output += '| :-- | :-- | :-- | :-: |\n';
|
||||
day.lines.forEach(line => {
|
||||
output += `| ${line.start} | ${line.description} | ${line.end} | ${line.hours} |\n`;
|
||||
});
|
||||
// Show in-progress task in the active day
|
||||
if (idx === activeDayIdx && currentTask) {
|
||||
output += `| ${currentTask.startTime} | ${currentTask.category} - ${currentTask.description} | ... | ... |\n`;
|
||||
}
|
||||
});
|
||||
|
||||
worklogOutput.value = output;
|
||||
}
|
||||
|
||||
// ===== CATEGORY SELECT =====
|
||||
function rebuildCategorySelect() {
|
||||
const current = taskCategory.value;
|
||||
taskCategory.innerHTML = '<option value="">-- SELECT CATEGORY --</option>';
|
||||
const all = [...builtinCategories, ...customCategories];
|
||||
all.forEach(cat => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = cat;
|
||||
opt.textContent = cat;
|
||||
taskCategory.appendChild(opt);
|
||||
});
|
||||
if (current) taskCategory.value = current;
|
||||
}
|
||||
|
||||
function renderCatList() {
|
||||
catList.innerHTML = '';
|
||||
const all = [
|
||||
...builtinCategories.map(c => ({ name: c, type: 'builtin' })),
|
||||
...customCategories.map(c => ({ name: c, type: 'custom' }))
|
||||
];
|
||||
all.forEach(({ name, type }) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = `cat-item ${type}`;
|
||||
row.innerHTML = `<span>${name}</span>`;
|
||||
if (type === 'custom') {
|
||||
const del = document.createElement('button');
|
||||
del.className = 'cat-delete';
|
||||
del.textContent = '[x]';
|
||||
del.onclick = () => {
|
||||
customCategories = customCategories.filter(c => c !== name);
|
||||
rebuildCategorySelect();
|
||||
renderCatList();
|
||||
};
|
||||
row.appendChild(del);
|
||||
}
|
||||
catList.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== LIGHTBOX CONTROLS =====
|
||||
function openStartLightbox() {
|
||||
taskCategory.value = '';
|
||||
taskDescription.value = '';
|
||||
rebuildCategorySelect();
|
||||
startLightbox.classList.add('active');
|
||||
taskCategory.focus();
|
||||
}
|
||||
function closeStartLightbox() { startLightbox.classList.remove('active'); }
|
||||
|
||||
function openEndLightbox() {
|
||||
if (!currentTask) {
|
||||
endLightboxBody.innerHTML = `
|
||||
<div class="alert-message">
|
||||
<strong>NO OPEN TASK EXISTS</strong><br>There is no active task to end.
|
||||
</div>`;
|
||||
btnCommitEnd.style.display = 'none';
|
||||
} else {
|
||||
const now = new Date();
|
||||
const duration = calculateDuration(currentTask.startDate, now);
|
||||
endLightboxBody.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label>Task</label>
|
||||
<div style="color:var(--neon-cyan);padding:0.5rem;border:1px solid var(--neon-cyan);">
|
||||
${currentTask.category} - ${currentTask.description}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Started At</label>
|
||||
<div style="color:var(--neon-lime);">${currentTask.startTime}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>End Time</label>
|
||||
<div style="color:var(--neon-red);">${formatTime(now)}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Duration (rounded to 0.5h)</label>
|
||||
<div style="color:var(--neon-yellow);font-size:1.25rem;">${duration} hours</div>
|
||||
</div>`;
|
||||
btnCommitEnd.style.display = 'block';
|
||||
}
|
||||
endLightbox.classList.add('active');
|
||||
}
|
||||
function closeEndLightbox() { endLightbox.classList.remove('active'); }
|
||||
|
||||
function openCatsLightbox() {
|
||||
renderCatList();
|
||||
newCatInput.value = '';
|
||||
catsLightbox.classList.add('active');
|
||||
newCatInput.focus();
|
||||
}
|
||||
function closeCatsLightbox() { catsLightbox.classList.remove('active'); }
|
||||
|
||||
// ===== HANDLERS =====
|
||||
btnStart.addEventListener('click', () => {
|
||||
if (!inputName.value.trim() || !inputOrg.value.trim()) {
|
||||
alert('Please enter Name and Organization first.');
|
||||
inputName.focus();
|
||||
return;
|
||||
}
|
||||
// Check if we've rolled into a new day
|
||||
const wasNew = ensureTodayExists();
|
||||
if (wasNew) generateWorklog();
|
||||
openStartLightbox();
|
||||
});
|
||||
|
||||
btnEnd.addEventListener('click', openEndLightbox);
|
||||
|
||||
btnCats.addEventListener('click', openCatsLightbox);
|
||||
|
||||
btnCommitStart.addEventListener('click', () => {
|
||||
const category = taskCategory.value;
|
||||
const description = taskDescription.value.trim();
|
||||
if (!category) { alert('Please select a category.'); taskCategory.focus(); return; }
|
||||
if (!description) { alert('Please enter a task description.'); taskDescription.focus(); return; }
|
||||
|
||||
const now = new Date();
|
||||
const day = getActiveDay();
|
||||
|
||||
if (currentTask) {
|
||||
const duration = calculateDuration(currentTask.startDate, now);
|
||||
day.lines.push({
|
||||
start: currentTask.startTime,
|
||||
description: `${currentTask.category} - ${currentTask.description} (switched at ${formatTime(now)})`,
|
||||
end: formatTime(now),
|
||||
hours: duration
|
||||
});
|
||||
}
|
||||
|
||||
currentTask = { startDate: now, startTime: formatTime(now), category, description };
|
||||
updateStatus(true, `${category} - ${description}`);
|
||||
generateWorklog();
|
||||
closeStartLightbox();
|
||||
});
|
||||
|
||||
btnCancelStart.addEventListener('click', closeStartLightbox);
|
||||
|
||||
btnCommitEnd.addEventListener('click', () => {
|
||||
if (!currentTask) return;
|
||||
const now = new Date();
|
||||
const duration = calculateDuration(currentTask.startDate, now);
|
||||
getActiveDay().lines.push({
|
||||
start: currentTask.startTime,
|
||||
description: `${currentTask.category} - ${currentTask.description}`,
|
||||
end: formatTime(now),
|
||||
hours: duration
|
||||
});
|
||||
currentTask = null;
|
||||
updateStatus(false);
|
||||
generateWorklog();
|
||||
closeEndLightbox();
|
||||
});
|
||||
|
||||
btnCancelEnd.addEventListener('click', closeEndLightbox);
|
||||
|
||||
btnAddCat.addEventListener('click', addCustomCategory);
|
||||
newCatInput.addEventListener('keydown', e => { if (e.key === 'Enter') addCustomCategory(); });
|
||||
|
||||
function addCustomCategory() {
|
||||
const val = newCatInput.value.trim().replace(/\s+/g, '-');
|
||||
if (!val) return;
|
||||
if ([...builtinCategories, ...customCategories].includes(val)) {
|
||||
newCatInput.value = '';
|
||||
return;
|
||||
}
|
||||
customCategories.push(val);
|
||||
rebuildCategorySelect();
|
||||
renderCatList();
|
||||
newCatInput.value = '';
|
||||
newCatInput.focus();
|
||||
}
|
||||
|
||||
btnCloseCats.addEventListener('click', closeCatsLightbox);
|
||||
|
||||
// ===== NEW DAY BUTTON =====
|
||||
btnNewDay.addEventListener('click', () => {
|
||||
if (!inputName.value.trim() || !inputOrg.value.trim()) {
|
||||
alert('Please enter Name and Organization first.');
|
||||
return;
|
||||
}
|
||||
const key = todayKey();
|
||||
// Always add a fresh entry for today even if one exists
|
||||
days.push({ key, date: formatDate(new Date()), lines: [] });
|
||||
activeDayIdx = days.length - 1;
|
||||
currentTask = null;
|
||||
updateStatus(false);
|
||||
generateWorklog();
|
||||
});
|
||||
|
||||
// ===== COPY =====
|
||||
btnCopy.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(worklogOutput.value);
|
||||
} catch {
|
||||
worklogOutput.removeAttribute('readonly');
|
||||
worklogOutput.select();
|
||||
document.execCommand('copy');
|
||||
worklogOutput.setAttribute('readonly', '');
|
||||
}
|
||||
btnCopy.textContent = 'COPIED!';
|
||||
btnCopy.classList.add('copied');
|
||||
setTimeout(() => { btnCopy.textContent = 'Copy'; btnCopy.classList.remove('copied'); }, 2000);
|
||||
});
|
||||
|
||||
// ===== EXPORT MARKDOWN =====
|
||||
btnExport.addEventListener('click', () => {
|
||||
const content = worklogOutput.value;
|
||||
if (!content.trim()) { alert('Nothing to export yet.'); return; }
|
||||
const name = (inputName.value.trim() || 'worklog').replace(/\s+/g, '_');
|
||||
const dateStr = todayKey();
|
||||
const filename = `worklog_${name}_${dateStr}.md`;
|
||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// ===== CLOSE ON OVERLAY CLICK / ESCAPE =====
|
||||
startLightbox.addEventListener('click', e => { if (e.target === startLightbox) closeStartLightbox(); });
|
||||
endLightbox.addEventListener('click', e => { if (e.target === endLightbox) closeEndLightbox(); });
|
||||
catsLightbox.addEventListener('click', e => { if (e.target === catsLightbox) closeCatsLightbox(); });
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { closeStartLightbox(); closeEndLightbox(); closeCatsLightbox(); }
|
||||
});
|
||||
|
||||
// ===== INIT =====
|
||||
ensureTodayExists();
|
||||
rebuildCategorySelect();
|
||||
generateWorklog();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user