From f5ddcad45ce9a87b2e5d91d072eeafbcb43f6866 Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Mon, 29 Dec 2025 14:12:46 -0300 Subject: [PATCH] restore legacy, include shorcut scripts --- dmapp/dmos/SETUP-KEYBOARD-SHORTCUTS.md | 85 ++++ dmapp/dmos/cyclework.py | 85 ++++ dmapp/dmos/datetime-clipboard.py | 76 ++++ dmold/dmweb/__init__.py | 20 + dmold/dmweb/__init__.pyc | Bin 0 -> 533 bytes dmold/dmweb/dm.py | 283 ++++++++++++ dmold/dmweb/dmcal.py | 177 ++++++++ dmold/dmweb/get_period_times.py | 550 +++++++++++++++++++++++ dmold/dmweb/run.py | 4 + dmold/dmweb/static/styles/dm.css | 7 + dmold/dmweb/templates/calendar.html | 32 ++ dmold/dmweb/templates/calendar_view.html | 244 ++++++++++ dmold/dmweb/templates/layout.html | 26 ++ dmold/dmweb/templates/main.html | 64 +++ dmold/dmweb/templates/main_content.html | 23 + dmold/dmweb/templates/pages.html | 16 + dmold/dmweb/templates/switches_view.html | 185 ++++++++ 17 files changed, 1877 insertions(+) create mode 100644 dmapp/dmos/SETUP-KEYBOARD-SHORTCUTS.md create mode 100644 dmapp/dmos/cyclework.py create mode 100644 dmapp/dmos/datetime-clipboard.py create mode 100644 dmold/dmweb/__init__.py create mode 100644 dmold/dmweb/__init__.pyc create mode 100644 dmold/dmweb/dm.py create mode 100644 dmold/dmweb/dmcal.py create mode 100644 dmold/dmweb/get_period_times.py create mode 100644 dmold/dmweb/run.py create mode 100644 dmold/dmweb/static/styles/dm.css create mode 100644 dmold/dmweb/templates/calendar.html create mode 100644 dmold/dmweb/templates/calendar_view.html create mode 100644 dmold/dmweb/templates/layout.html create mode 100644 dmold/dmweb/templates/main.html create mode 100644 dmold/dmweb/templates/main_content.html create mode 100644 dmold/dmweb/templates/pages.html create mode 100644 dmold/dmweb/templates/switches_view.html diff --git a/dmapp/dmos/SETUP-KEYBOARD-SHORTCUTS.md b/dmapp/dmos/SETUP-KEYBOARD-SHORTCUTS.md new file mode 100644 index 0000000..f01d9ab --- /dev/null +++ b/dmapp/dmos/SETUP-KEYBOARD-SHORTCUTS.md @@ -0,0 +1,85 @@ +# Date/Time Clipboard Shortcuts Setup + +This replaces espanso with a cleaner clipboard-based solution that won't trigger "Updated keyboard layout" notifications. + +## How It Works + +Instead of text expansion, keyboard shortcuts copy the formatted date/time to clipboard. You then paste with `Ctrl+V`. + +## Available Formats + +- **wd**: ISO week.day (e.g., `52.4`) +- **3t**: 360-time division (e.g., `331`) +- **uid**: Random UUID 8 chars (e.g., `a8b9bf0a`) + +## GNOME Keyboard Shortcuts Setup + +1. Open Settings → Keyboard → Keyboard Shortcuts (or run: `gnome-control-center keyboard`) + +2. Scroll to bottom and click "+ Add Custom Shortcut" + +3. Add three shortcuts: + +### Shortcut 1: Week Day +- **Name**: `Copy Week.Day` +- **Command**: `/home/mariano/wdir/dm/dmapp/dmos/datetime-clipboard.py wd` +- **Shortcut**: `Ctrl+Super+W` + +### Shortcut 2: 360-Time +- **Name**: `Copy 360-Time` +- **Command**: `/home/mariano/wdir/dm/dmapp/dmos/datetime-clipboard.py 3t` +- **Shortcut**: `Ctrl+Super+T` + +### Shortcut 3: UUID +- **Name**: `Copy UUID` +- **Command**: `/home/mariano/wdir/dm/dmapp/dmos/datetime-clipboard.py uid` +- **Shortcut**: `Ctrl+Super+U` + +## Usage + +1. Press the keyboard shortcut (e.g., `Ctrl+Super+W`) +2. Paste with `Ctrl+V` wherever you need it + +## Advantages Over Espanso + +- No keyboard layout notifications +- Works reliably on Wayland +- Simpler architecture (no daemon running) +- More control over when to paste +- Works in password fields and other protected inputs + +## Removing Espanso (Optional) + +If you want to fully switch away from espanso: + +```bash +# Stop espanso service +espanso stop + +# Disable autostart +espanso service unregister + +# Uninstall (if desired) +# sudo apt remove espanso # or however you installed it +``` + +## Testing + +```bash +# Test each format +/home/mariano/wdir/dm/dmapp/dmos/datetime-clipboard.py wd +/home/mariano/wdir/dm/dmapp/dmos/datetime-clipboard.py 3t +/home/mariano/wdir/dm/dmapp/dmos/datetime-clipboard.py uid + +# Then paste to verify clipboard contents +``` + +## Cross-Platform Notes + +This solution is part of the deskmeter cross-platform configuration effort: + +- **Linux (Wayland/X11)**: Uses `wl-copy` or `xclip` for clipboard +- **Windows**: Would require AutoHotkey or similar for global shortcuts +- **macOS**: Would use `pbcopy` for clipboard + +The core datetime logic is platform-independent Python. Only clipboard mechanism needs platform-specific handling. diff --git a/dmapp/dmos/cyclework.py b/dmapp/dmos/cyclework.py new file mode 100644 index 0000000..24bc39b --- /dev/null +++ b/dmapp/dmos/cyclework.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +import os +import subprocess + +# Define the list of workspaces to cycle through +WORKSPACES = [3, 6, 7, 8] + +LAST_WS_FILE = "/tmp/lastws" + + +def get_current_workspace(): + result = subprocess.run(["wmctrl", "-d"], capture_output=True, text=True) + lines = result.stdout.splitlines() + + for line in lines: + if "*" in line: # The active workspace has a '*' symbol + try: + current_workspace = int(line.split()[0]) + 1 + print(f"Current workspace detected: {current_workspace}") + return current_workspace + except (IndexError, ValueError): + print("Error parsing current workspace.") + return None + + print("No active workspace found.") + return None + + +def switch_to_workspace(workspace): + print(f"Switching to workspace {workspace}") + subprocess.run(["wmctrl", "-s", str(workspace - 1)]) + save_last_used_workspace(workspace) + + +def save_last_used_workspace(workspace): + with open(LAST_WS_FILE, "w") as f: + f.write(str(workspace)) + + +def get_last_used_workspace(): + if os.path.exists(LAST_WS_FILE): + with open(LAST_WS_FILE, "r") as f: + try: + return int(f.read().strip()) + except ValueError: + return WORKSPACES[0] + return WORKSPACES[0] + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2 or sys.argv[1] not in ["up", "down", "current"]: + print("Usage: python cyclework.py [up|down|current]") + sys.exit(1) + + direction = sys.argv[1] + current = get_current_workspace() + + if current is None: + sys.exit(1) + + if direction == "current": + if current in WORKSPACES: + print("Already in a workspace in the cycle.") + sys.exit(0) + + last_used = get_last_used_workspace() + print(f"Switching to last used workspace in cycle: {last_used}") + switch_to_workspace(last_used) + sys.exit(0) + + try: + index = WORKSPACES.index(current) + except ValueError: + print("Current workspace is not in the list.") + sys.exit(1) + + if direction == "up": + index = (index + 1) % len(WORKSPACES) + else: # direction == 'down' + index = (index - 1) % len(WORKSPACES) + + next_workspace = WORKSPACES[index] + switch_to_workspace(next_workspace) diff --git a/dmapp/dmos/datetime-clipboard.py b/dmapp/dmos/datetime-clipboard.py new file mode 100644 index 0000000..f3def73 --- /dev/null +++ b/dmapp/dmos/datetime-clipboard.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +Date/time clipboard utility for GNOME keyboard shortcuts. +Copies various date/time formats to clipboard for quick pasting. + +Usage: + datetime-clipboard.py wd # ISO week.day (e.g., 51.2) + datetime-clipboard.py 3t # 360-time format (0-359) + datetime-clipboard.py uid # UUID first 8 chars +""" +import sys +import subprocess +from datetime import datetime +import uuid + + +def copy_to_clipboard(text): + """Copy text to clipboard using wl-copy (Wayland) or xclip (X11) as fallback.""" + try: + # Try wl-copy first (Wayland) + subprocess.run(['wl-copy'], input=text.encode(), check=True) + except FileNotFoundError: + # Fallback to xclip (X11) + try: + subprocess.run(['xclip', '-selection', 'clipboard'], + input=text.encode(), check=True) + except FileNotFoundError: + print("Error: Neither wl-copy nor xclip found. Install wl-clipboard or xclip.", + file=sys.stderr) + sys.exit(1) + + +def get_weekday(): + """ISO week.day format (e.g., 51.2)""" + ywd = datetime.now().isocalendar() + return f"{ywd[1]}.{ywd[2]}" + + +def get_360time(): + """Day divided into 360 parts (0-359)""" + t = datetime.now() + return str((t.hour * 60 + t.minute) // (1440 // 360)) + + +def get_uid(): + """First 8 characters of UUID""" + return str(uuid.uuid4())[:8] + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + format_type = sys.argv[1].lower() + + formatters = { + 'wd': get_weekday, + '3t': get_360time, + 'uid': get_uid, + } + + if format_type not in formatters: + print(f"Unknown format: {format_type}") + print(__doc__) + sys.exit(1) + + text = formatters[format_type]() + copy_to_clipboard(text) + + # Optional: print to stdout for verification + print(f"Copied to clipboard: {text}") + + +if __name__ == '__main__': + main() diff --git a/dmold/dmweb/__init__.py b/dmold/dmweb/__init__.py new file mode 100644 index 0000000..7f04fe5 --- /dev/null +++ b/dmold/dmweb/__init__.py @@ -0,0 +1,20 @@ +from flask import Flask + +from . import dm, dmcal + + +def create_app(): + app = Flask("deskmeter") + + app.debug = True + app.register_blueprint(dm.dmbp) + + # Register custom Jinja2 filters + @app.template_filter('hash') + def hash_filter(s): + """Return hash of string for consistent color generation""" + if s is None: + return 0 + return hash(str(s)) + + return app diff --git a/dmold/dmweb/__init__.pyc b/dmold/dmweb/__init__.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ed385b7ceaf6004226102afc070346e49a085957 GIT binary patch literal 533 zcmbtQy-ve05dQ3>h*ls~3_L?v$N(ckNJxwwQl<=8No|VTxQ%Q_2ni+_cpDy$2Y@>l z!2@9VKHvX+zU=dGBHlml7Wg@m-Yb%DMxf~mbbu461q9m?orKF!XK*>{oV*#{fO-U- zV_uA?&VJD;1Zk1Yo307#f;&eB?UGl!Mbo9ZoXCM|l5j#0)7)2q7t#fu0VNq%8Dfsr z2ow@z&r{P`98--pVeL#bzStwHBH>)%)(>Vk&$Pob9+AtNr45v++HNqtx0_fcuAHu2 z+wYiXWHo(X(1QfwoV4;`>rCkyZ=21wblUpzcXFv66%am}wp7Y)Y*gyBe~J?-TzJz& zqbPyLgna_Y134B`IZKoi$$MVajy!KJwMyhFUQop!{Uh{GR!rf-ZS~NZOa6agVpDM} FzX6EbZD0TZ literal 0 HcmV?d00001 diff --git a/dmold/dmweb/dm.py b/dmold/dmweb/dm.py new file mode 100644 index 0000000..a0e611f --- /dev/null +++ b/dmold/dmweb/dm.py @@ -0,0 +1,283 @@ +from datetime import datetime, timedelta + +from flask import Blueprint, render_template, jsonify + +from .get_period_times import get_period_totals, task_or_none, timezone, get_work_period_totals, get_current_task_info, convert_seconds, get_task_time_seconds, get_task_blocks_calendar, get_raw_switches + +dmbp = Blueprint("deskmeter", __name__, url_prefix="/", template_folder="templates") + + +@dmbp.route("/favicon.ico") +def favicon(): + return "", 204 # No Content + + +@dmbp.route("/calendar") +@dmbp.route("/calendar/") +@dmbp.route("/calendar////") +def calendar_view(scope="daily", year=None, month=None, day=None): + """ + Google Calendar-style view showing task blocks at their actual times. + """ + task = None + + if not year: + year = datetime.today().year + if not month: + month = datetime.today().month + if not day: + day = datetime.today().day + + base_date = datetime(year, month, day).replace(hour=0, minute=0, second=0, tzinfo=timezone) + + if scope == "daily": + start = base_date + end = base_date.replace(hour=23, minute=59, second=59) + blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=60) + prev_date = base_date - timedelta(days=1) + next_date = base_date + timedelta(days=1) + days = [base_date] + + elif scope == "weekly": + start = base_date - timedelta(days=base_date.weekday()) + end = start + timedelta(days=6, hours=23, minutes=59, seconds=59) + blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=300) + prev_date = start - timedelta(days=7) + next_date = start + timedelta(days=7) + days = [start + timedelta(days=i) for i in range(7)] + + elif scope == "monthly": + start = base_date.replace(day=1) + if month == 12: + end = datetime(year + 1, 1, 1, tzinfo=timezone) - timedelta(seconds=1) + else: + end = datetime(year, month + 1, 1, tzinfo=timezone) - timedelta(seconds=1) + blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=600) + if month == 1: + prev_date = datetime(year - 1, 12, 1, tzinfo=timezone) + else: + prev_date = datetime(year, month - 1, 1, tzinfo=timezone) + if month == 12: + next_date = datetime(year + 1, 1, 1, tzinfo=timezone) + else: + next_date = datetime(year, month + 1, 1, tzinfo=timezone) + days = [] + current = start + while current <= end: + days.append(current) + current += timedelta(days=1) + else: + scope = "daily" + start = base_date + end = base_date.replace(hour=23, minute=59, second=59) + blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=60) + prev_date = base_date - timedelta(days=1) + next_date = base_date + timedelta(days=1) + days = [base_date] + + return render_template( + "calendar_view.html", + scope=scope, + blocks=blocks, + start=start, + end=end, + base_date=base_date, + prev_date=prev_date, + next_date=next_date, + days=days, + auto_refresh=False + ) + + +@dmbp.route("/switches") +@dmbp.route("/switches/") +@dmbp.route("/switches////") +def switches_view(scope="daily", year=None, month=None, day=None): + """ + Raw switches view showing all switch documents. + """ + task = None + + if not year: + year = datetime.today().year + if not month: + month = datetime.today().month + if not day: + day = datetime.today().day + + base_date = datetime(year, month, day).replace(hour=0, minute=0, second=0, tzinfo=timezone) + + if scope == "daily": + start = base_date + end = base_date.replace(hour=23, minute=59, second=59) + prev_date = base_date - timedelta(days=1) + next_date = base_date + timedelta(days=1) + + elif scope == "weekly": + start = base_date - timedelta(days=base_date.weekday()) + end = start + timedelta(days=6, hours=23, minutes=59, seconds=59) + prev_date = start - timedelta(days=7) + next_date = start + timedelta(days=7) + + elif scope == "monthly": + start = base_date.replace(day=1) + if month == 12: + end = datetime(year + 1, 1, 1, tzinfo=timezone) - timedelta(seconds=1) + else: + end = datetime(year, month + 1, 1, tzinfo=timezone) - timedelta(seconds=1) + if month == 1: + prev_date = datetime(year - 1, 12, 1, tzinfo=timezone) + else: + prev_date = datetime(year, month - 1, 1, tzinfo=timezone) + if month == 12: + next_date = datetime(year + 1, 1, 1, tzinfo=timezone) + else: + next_date = datetime(year, month + 1, 1, tzinfo=timezone) + else: + scope = "daily" + start = base_date + end = base_date.replace(hour=23, minute=59, second=59) + prev_date = base_date - timedelta(days=1) + next_date = base_date + timedelta(days=1) + + raw_switches = get_raw_switches(start, end, task) + + return render_template( + "switches_view.html", + scope=scope, + switches=raw_switches, + start=start, + end=end, + base_date=base_date, + prev_date=prev_date, + next_date=next_date, + auto_refresh=False + ) + + +@dmbp.route("/") +@dmbp.route("/") +def index(task=None): + """ + Show total time used in each desktop for today + """ + task = task_or_none(task) + + start = datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone) + end = datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone) + + rows = get_period_totals(start, end, task) + + # Get current task info + current_task_id, current_task_path = get_current_task_info() + current_task_time = None + if current_task_id: + total_seconds = get_task_time_seconds(start, end, current_task_id) + if total_seconds > 0: + current_task_time = convert_seconds(total_seconds) + + return render_template("main.html", rows=rows, current_task_path=current_task_path, current_task_time=current_task_time, auto_refresh=True) + + +@dmbp.route("/api/current_task") +def api_current_task(): + """ + JSON API endpoint returning current task information + """ + current_task_id, current_task_path = get_current_task_info() + return jsonify({ + "task_id": current_task_id, + "task_path": current_task_path or "no task" + }) + + +@dmbp.route("/api/today") +@dmbp.route("/api/today/") +def api_today(task=None): + """ + HTML fragment API endpoint for today's data (for AJAX updates) + """ + task = task_or_none(task) + + start = datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone) + end = datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone) + + rows = get_period_totals(start, end, task) + + # Get current task info + current_task_id, current_task_path = get_current_task_info() + current_task_time = None + if current_task_id: + total_seconds = get_task_time_seconds(start, end, current_task_id) + if total_seconds > 0: + current_task_time = convert_seconds(total_seconds) + + return render_template("main_content.html", rows=rows, current_task_path=current_task_path, current_task_time=current_task_time) + + +@dmbp.route("/day//") +@dmbp.route("/day///") +def oneday( + month, + day, + task=None, +): + task = task_or_none(task) + + start = datetime(2025, month, day).replace( + hour=0, minute=0, second=0, tzinfo=timezone + ) + + end = datetime(2025, month, day).replace( + hour=23, minute=59, second=59, tzinfo=timezone + ) + + rows = get_period_totals(start, end) + + return render_template("pages.html", rows=rows, auto_refresh=True) + + +@dmbp.route("/period//") +def period(start, end): + start = datetime(*map(int, start.split("-"))).replace( + hour=0, minute=0, second=0, tzinfo=timezone + ) + + end = datetime(*map(int, end.split("-"))).replace( + hour=23, minute=59, second=59, tzinfo=timezone + ) + + rows = get_period_totals(start, end) + + return render_template("pages.html", rows=rows, auto_refresh=True) + + +@dmbp.route("/work") +def work(): + """ + Show total time used per work project for today + """ + start = datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone) + end = datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone) + + rows = get_work_period_totals(start, end) + + return render_template("main.html", rows=rows, auto_refresh=False) + + +@dmbp.route("/totals") +@dmbp.route("/totals/") +def totals(task=None): + """ + Show total time used in each desktop for all time + """ + + task = task_or_none(task) + + start = datetime(2020, 1, 1).replace(hour=0, minute=0, second=0, tzinfo=timezone) + + end = datetime(2030, 1, 1).replace(hour=23, minute=59, second=59, tzinfo=timezone) + + rows = get_period_totals(start, end) + + return render_template("pages.html", rows=rows, auto_refresh=True) diff --git a/dmold/dmweb/dmcal.py b/dmold/dmweb/dmcal.py new file mode 100644 index 0000000..cb38cf5 --- /dev/null +++ b/dmold/dmweb/dmcal.py @@ -0,0 +1,177 @@ +import calendar +from datetime import datetime +from pprint import pprint + +from flask import Blueprint, render_template + +# import pytz +from .dm import dmbp +from .get_period_times import ( + get_period_totals, + read_and_extract, + task_file, + task_or_none, + timezone, + get_work_period_totals, +) + + + +class DMHTMLCalendar(calendar.HTMLCalendar): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.use_work_projects = False + self.task = None + + def setcalmonth(self, month): + self.dmmonth = month + + def setcalyear(self, year): + self.dmyear = year + + def settask(self, task): + self.task = task + + def set_work_projects_mode(self, enabled=True): + self.use_work_projects = enabled + + def oneday(self, month, day): + current_year = datetime.today().year + start = datetime(self.dmyear, month, day).replace( + hour=0, minute=0, second=0, tzinfo=timezone + ) + end = datetime(self.dmyear, month, day).replace( + hour=23, minute=59, second=59, tzinfo=timezone + ) + + if self.use_work_projects: + rows = get_work_period_totals(start, end) + else: + rows = get_period_totals(start, end, self.task) + + returnstr = "" + for row in rows: + returnstr += "".format( + row["ws"], row["total"] + ) + + returnstr += "
{}{}
" + return returnstr + + def oneweek(self, month, week): + start_day = None + end_day = None + + for d, wd in week: + if d == 0: + continue + else: + start_day = d + break + + for d, wd in reversed(week): + if d == 0: + continue + else: + end_day = d + break + + start = datetime(self.dmyear, month, start_day).replace( + hour=0, minute=0, second=0, tzinfo=timezone + ) + + end = datetime(self.dmyear, month, end_day).replace( + hour=23, minute=59, second=59, tzinfo=timezone + ) + + if self.use_work_projects: + rows = get_work_period_totals(start, end) + else: + rows = get_period_totals(start, end, self.task) + + print(rows) + + returnstr = "" + for row in rows: + returnstr += "".format( + row["ws"], row["total"] + ) + + returnstr += "
{}{}
" + return returnstr + + def formatweekheader(self): + """ + Return a header for a week as a table row. + """ + s = "".join(self.formatweekday(i) for i in self.iterweekdays()) + s += "Week Totals" + return "%s" % s + + def formatweek(self, theweek): + """ + Return a complete week as a table row. + """ + s = "".join(self.formatday(d, wd) for (d, wd) in theweek) + s += "{}".format(self.oneweek(self.dmmonth, theweek)) + return "%s" % s + + def formatday(self, day, weekday): + """ + Return a day as a table cell. + """ + if day == 0: + return ' ' # day outside month + else: + return '%s' % ( + self.cssclasses[weekday], + self.oneday(self.dmmonth, day), + ) + + +@dmbp.route("/workmonth") +@dmbp.route("/workmonth/") +@dmbp.route("/workmonth//") +def workmonth(month=None, year=None): + usemonth = datetime.today().month + useyear = datetime.today().year + + if month: + usemonth = month + + if year: + useyear = year + + cal = DMHTMLCalendar(calendar.SATURDAY) + + cal.set_work_projects_mode(True) + cal.setcalmonth(usemonth) + cal.setcalyear(useyear) + + return render_template("calendar.html", content=cal.formatmonth(useyear, usemonth), auto_refresh=False) + + +@dmbp.route("/month") +@dmbp.route("/month/") +@dmbp.route("/month//") +@dmbp.route("/month/") +@dmbp.route("/month//") +@dmbp.route("/month///") +def month(month=None, year=None, task=None): + usemonth = datetime.today().month + useyear = datetime.today().year + + if month: + usemonth = month + + if year: + useyear = year + + cal = DMHTMLCalendar(calendar.SATURDAY) + + cal.settask(task_or_none(task)) + + cal.setcalmonth(usemonth) + cal.setcalyear(useyear) + + return render_template("calendar.html", content=cal.formatmonth(useyear, usemonth), auto_refresh=True) diff --git a/dmold/dmweb/get_period_times.py b/dmold/dmweb/get_period_times.py new file mode 100644 index 0000000..9b166bb --- /dev/null +++ b/dmold/dmweb/get_period_times.py @@ -0,0 +1,550 @@ +from collections import Counter, defaultdict +from datetime import datetime, timedelta +from pathlib import Path + +from pymongo import MongoClient +from zoneinfo import ZoneInfo + +timezone = ZoneInfo("America/Argentina/Buenos_Aires") +utctz = ZoneInfo("UTC") + +client = MongoClient() +db = client.deskmeter +switches = db.switch +tasks = db.task +task_history = db.task_history + + +task_file = "/home/mariano/LETRAS/adm/task/main" +task_dir = Path(task_file).parent + + +def parse_task_line(line): + """Parse a task line to extract task name and ID.""" + line = line.strip() + if not line: + return None, None + + parts = line.split("|") + if len(parts) > 1: + task_name = parts[0].strip() + id_parts = parts[1].split() + if id_parts: + task_id = id_parts[0].strip() + return task_name, task_id + return task_name, None + + return parts[0].strip(), None + + +def load_task_from_files(task_id): + """Search task directory files for a task ID and load it into task_history.""" + for task_filepath in task_dir.glob("*"): + if not task_filepath.is_file(): + continue + + current_path = [] + try: + with open(task_filepath, "r") as f: + for line in f: + if not line.strip(): + continue + + indent = len(line) - len(line.lstrip()) + level = indent // 4 + + task_name, found_id = parse_task_line(line) + if task_name is None: + continue + + current_path = current_path[:level] + current_path.append(task_name) + full_path = "/".join(current_path) + + if found_id == task_id: + # Found it! Insert into task_history + task_history.update_one( + {"task_id": task_id}, + {"$set": { + "path": full_path, + "task_id": task_id, + "source_file": task_filepath.name + }}, + upsert=True + ) + return full_path + except: + # Skip files that can't be read + continue + + return None + + +def get_task_path(task_id): + """ + Get task path from tasks collection, falling back to task_history. + If not found, searches task directory files and populates task_history on-demand. + """ + if not task_id: + return None + + # Try current tasks first + task_doc = tasks.find_one({"task_id": task_id}) + if task_doc and "path" in task_doc: + return task_doc["path"] + + # Try task history cache + task_doc = task_history.find_one({"task_id": task_id}) + if task_doc and "path" in task_doc: + return task_doc["path"] + + # Not in cache, search files and populate history + task_path = load_task_from_files(task_id) + if task_path: + return task_path + + # Still not found, return ID as fallback + return task_id + + +def get_current_task_info(): + """Get current task ID and path from state and tasks collection""" + states = db.state + current_doc = states.find_one({"_id": "current"}) + + if not current_doc or "task" not in current_doc: + return None, None + + task_id = current_doc["task"] + task_doc = tasks.find_one({"task_id": task_id}) + + if task_doc and "path" in task_doc: + return task_id, task_doc["path"] + + return task_id, None + + +def get_task_time_seconds(start, end, task_id, workspaces=None): + """Get total seconds for a task within a time period using MongoDB aggregation.""" + if workspaces is None: + workspaces = ["Plan", "Think", "Work"] + + pipeline = [ + { + "$match": { + "date": {"$gte": start, "$lte": end}, + "task": task_id, + "workspace": {"$in": workspaces} + } + }, + { + "$group": { + "_id": None, + "total_seconds": {"$sum": "$delta"} + } + } + ] + + result = list(switches.aggregate(pipeline)) + + if result and len(result) > 0: + return result[0]["total_seconds"] + + return 0 + + +def task_or_none(task=None): + if not task: + task = read_and_extract(task_file) + + if task == "all": + task = None + + return task + + +def now(): + return datetime.now(timezone) + + +def convert_seconds(seconds, use_days=False): + days = seconds // 86400 + hours = (seconds % 86400) // 3600 + minutes = (seconds % 3600) // 60 + remaining_seconds = seconds % 60 + + if use_days: + return "{} days, {:02d}:{:02d}:{:02d}".format( + days, hours, minutes, remaining_seconds + ) + return "{:02d}:{:02d}:{:02d}".format(hours + days * 24, minutes, remaining_seconds) + + +def extract(line): + if line.rstrip().endswith("*"): + pipe_index = line.find("|") + if pipe_index != -1 and len(line) > pipe_index + 8: + value = line[pipe_index + 1 : pipe_index + 9] + return value + return None + + +def read_and_extract(file_path): + with open(file_path, "r") as file: + for line in file: + value = extract(line) + if value: + return value + return None + + +def get_work_period_totals(start, end): + """Get period totals grouped by task with full path.""" + # Get all tasks with time in the period + pipeline = [ + { + "$match": { + "date": {"$gte": start, "$lte": end}, + "workspace": {"$in": ["Plan", "Think", "Work"]}, + "task": {"$exists": True, "$ne": None} + } + }, + { + "$group": { + "_id": "$task", + "total_seconds": {"$sum": "$delta"} + } + } + ] + + results = list(switches.aggregate(pipeline)) + combined_rows = [] + + for result in results: + task_id = result["_id"] + total_seconds = result["total_seconds"] + + if total_seconds > 0: + # Get task path with history fallback + task_path = get_task_path(task_id) + + combined_rows.append({ + "ws": task_path, + "total": convert_seconds(total_seconds) + }) + + # Sort by path for consistency + combined_rows.sort(key=lambda x: x["ws"]) + return combined_rows + + +def get_task_blocks_calendar(start, end, task=None, min_block_seconds=300): + """ + Get task blocks for calendar-style visualization. + Groups consecutive switches to the same task into blocks, tracking active/idle time. + + Returns list of blocks: + [{ + 'task_id': str, + 'task_path': str, + 'start': datetime, + 'end': datetime, + 'duration': int (total seconds), + 'active_seconds': int (Plan/Think/Work time), + 'idle_seconds': int (Other/Away time), + 'active_ratio': float (0.0 to 1.0) + }, ...] + """ + task_query = {"$in": task.split(",")} if task else {} + + match_query = {"date": {"$gte": start, "$lte": end}} + if task_query: + match_query["task"] = task_query + + # Get all switches in period, sorted by date + raw_switches = list(switches.find(match_query).sort("date", 1)) + + if not raw_switches: + return [] + + blocks = [] + current_block = None + + for switch in raw_switches: + ws = switch["workspace"] + task_id = switch.get("task") + switch_start = switch["date"].replace(tzinfo=utctz).astimezone(timezone) + switch_duration = switch["delta"] + switch_end = switch_start + timedelta(seconds=switch_duration) + + is_active = ws in ["Plan", "Think", "Work"] + + # Start new block if task changed + if current_block is None or current_block["task_id"] != task_id: + if current_block is not None: + blocks.append(current_block) + + # Get task path with history fallback + task_path = get_task_path(task_id) or "No Task" + + current_block = { + "task_id": task_id, + "task_path": task_path, + "start": switch_start, + "end": switch_end, + "duration": switch_duration, + "active_seconds": switch_duration if is_active else 0, + "idle_seconds": 0 if is_active else switch_duration + } + else: + # Extend current block + current_block["end"] = switch_end + current_block["duration"] += switch_duration + if is_active: + current_block["active_seconds"] += switch_duration + else: + current_block["idle_seconds"] += switch_duration + + # Add final block + if current_block is not None: + blocks.append(current_block) + + # Filter out very short blocks and calculate active ratio + filtered_blocks = [] + for block in blocks: + if block["duration"] >= min_block_seconds: + block["active_ratio"] = block["active_seconds"] / block["duration"] if block["duration"] > 0 else 0 + filtered_blocks.append(block) + + return filtered_blocks + + +def get_raw_switches(start, end, task=None): + """ + Get all raw switch documents in the period. + + Returns list of switches: + [{ + 'workspace': str, + 'task_id': str, + 'task_path': str, + 'date': datetime, + 'delta': int (seconds) + }, ...] + """ + task_query = {"$in": task.split(",")} if task else {} + + match_query = {"date": {"$gte": start, "$lte": end}} + if task_query: + match_query["task"] = task_query + + raw_switches = list(switches.find(match_query).sort("date", 1)) + + result = [] + for switch in raw_switches: + task_id = switch.get("task") + # Get task path with history fallback + task_path = get_task_path(task_id) or "No Task" + + result.append({ + "workspace": switch["workspace"], + "task_id": task_id, + "task_path": task_path, + "date": switch["date"].replace(tzinfo=utctz).astimezone(timezone), + "delta": switch["delta"] + }) + + return result + + +def get_period_totals(start, end, task=None): + task_query = {"$in": task.split(",")} if task else {} + + match_query = {"date": {"$gte": start, "$lte": end}} + if task_query: + match_query["task"] = task_query + + pipeline = [ + {"$match": match_query}, + {"$sort": {"date": 1}}, + { + "$group": { + "_id": None, + "documents_in_range": {"$push": "$$ROOT"}, + "first_doc": {"$first": "$$ROOT"}, + "last_doc": {"$last": "$$ROOT"}, + } + }, + { + "$lookup": { + "from": "switch", + "let": {"first_date": "$first_doc.date", "task": "$first_doc.task"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$and": [ + { + "$lt": ["$date", "$$first_date"] + }, # Only before the first date + { + "$eq": ["$task", "$$task"] + }, # Must have the same task + ] + } + } + }, + {"$sort": {"date": -1}}, # Get the most recent (closest) document + {"$limit": 1}, # Only the immediate previous document + ], + "as": "before_first", + } + }, + { + "$project": { + "documents": { + "$concatArrays": [ + {"$ifNull": ["$before_first", []]}, # Add only if found + "$documents_in_range", + ] + } + } + }, + {"$unwind": "$documents"}, + {"$replaceRoot": {"newRoot": "$documents"}}, + { + "$group": { + "_id": "$workspace", + "total": {"$sum": "$delta"}, + } + }, + ] + + results = list(switches.aggregate(pipeline)) + + if not results: + return [{"ws": "No Data", "total": ""}] + + pipeline_before_after = [ + # Match documents within the date range + {"$match": match_query}, + {"$sort": {"date": 1}}, + { + "$group": { + "_id": None, + "first_doc": {"$first": "$$ROOT"}, + "last_doc": {"$last": "$$ROOT"}, + } + }, + # Lookup to get one document before the first document in the range + { + "$lookup": { + "from": "switch", + "let": {"first_date": "$first_doc.date", "task": "$first_doc.task"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$and": [ + { + "$lt": ["$date", "$$first_date"] + }, # Only before the first date + { + "$eq": ["$task", "$$task"] + }, # Must have the same task + ] + } + } + }, + {"$sort": {"date": -1}}, # Get the most recent (closest) document + {"$limit": 1}, # Only the immediate previous document + ], + "as": "before_first", + } + }, + { + "$project": { + "before_first": { + "$ifNull": [{"$arrayElemAt": ["$before_first", 0]}, ""] + }, + "last_doc": "$last_doc", # Include the last_doc from the matched period + } + }, + ] + + aux_results = list(switches.aggregate(pipeline_before_after)) + + # Safety check: if aux_results is empty, return early with no data + if not aux_results: + return [{"ws": "No Data", "total": ""}] + + bfirst = aux_results[0]["before_first"] + start_delta = 0 + + if bfirst: + bfdate = bfirst["date"].replace(tzinfo=utctz) + time_since_bfirst = round((start - bfdate.astimezone(timezone)).total_seconds()) + + # Only apply start_delta if the before_first switch actually crosses into the period + # If time_since_bfirst > bfirst["delta"], the switch ended before the period started + if time_since_bfirst <= bfirst["delta"]: + start_delta = time_since_bfirst + + ldoc = aux_results[0]["last_doc"] + lastdate = ldoc["date"].replace(tzinfo=utctz) + end_delta = round((end - lastdate.astimezone(timezone)).total_seconds()) + + rows = [] + active_vs_idle = {"Active": 0, "Idle": 0} + + for result in results: + if bfirst: + if result["_id"] == bfirst["workspace"]: + # Safety: ensure start_delta doesn't exceed total + adjustment = min(start_delta, result["total"]) + result["total"] -= adjustment + + if end < now(): + if result["_id"] == ldoc["workspace"]: + # Safety: ensure we don't subtract more than the total + adjustment = ldoc["delta"] - end_delta + safe_adjustment = min(adjustment, result["total"]) + result["total"] -= safe_adjustment + + for result in results: + if result["total"] > 0: + rows.append( + {"ws": result["_id"], "total": convert_seconds(result["total"])} + ) + if result["_id"] in ["Think", "Plan", "Work"]: + active_vs_idle["Active"] += result["total"] + if result["_id"] in ["Away", "Other"]: + active_vs_idle["Idle"] += result["total"] + + order = ["Plan", "Think", "Work", "Other", "Away", "Active", "Idle"] + + rows = sorted(rows, key=lambda x: order.index(x["ws"])) + + for k, v in active_vs_idle.items(): + rows.append({"ws": k, "total": convert_seconds(v)}) + + return rows + + +# print( +# get_period_totals( +# datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone) +# - timedelta(days=1), +# datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone) +# - timedelta(days=1), +# # "ffbe198e", +# ) +# ) + +# print( +# get_period_totals( +# datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone), +# datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone), +# "5fc751ec", +# ) +# ) diff --git a/dmold/dmweb/run.py b/dmold/dmweb/run.py new file mode 100644 index 0000000..607ba28 --- /dev/null +++ b/dmold/dmweb/run.py @@ -0,0 +1,4 @@ +from dmweb import create_app + +app = create_app() +app.run(host="0.0.0.0", debug=True, threaded=True, port=10000) diff --git a/dmold/dmweb/static/styles/dm.css b/dmold/dmweb/static/styles/dm.css new file mode 100644 index 0000000..14dc11a --- /dev/null +++ b/dmold/dmweb/static/styles/dm.css @@ -0,0 +1,7 @@ +.sat { + height: 200px; +} + +table { + border: 1px solid black; +} \ No newline at end of file diff --git a/dmold/dmweb/templates/calendar.html b/dmold/dmweb/templates/calendar.html new file mode 100644 index 0000000..5f85e93 --- /dev/null +++ b/dmold/dmweb/templates/calendar.html @@ -0,0 +1,32 @@ +{% extends 'layout.html' %} + +{% block head %} + + + + +{% endblock head %} + + +{% block content %} + + {{ content | safe }} + +{% endblock content %} \ No newline at end of file diff --git a/dmold/dmweb/templates/calendar_view.html b/dmold/dmweb/templates/calendar_view.html new file mode 100644 index 0000000..2ea9f33 --- /dev/null +++ b/dmold/dmweb/templates/calendar_view.html @@ -0,0 +1,244 @@ +{% extends 'layout.html' %} + +{% block head %} + +{% endblock head %} + +{% block content %} + + + +
+ + {% if scope == 'daily' %} + {{ base_date.strftime('%Y-%m-%d %A') }} + {% elif scope == 'weekly' %} + Week of {{ start.strftime('%Y-%m-%d') }} + {% elif scope == 'monthly' %} + {{ base_date.strftime('%B %Y') }} + {% endif %} + +
+ +
+
+
+ {% for hour in range(24) %} +
{{ '%02d:00'|format(hour) }}
+ {% endfor %} +
+ +
+ {% for day in days %} +
+
+ {{ day.strftime('%a %d') if scope != 'daily' else day.strftime('%A') }} +
+
+ {% for hour in range(24) %} +
+ {% endfor %} + + {% for block in blocks %} + {% if block.start.date() == day.date() %} + {% set start_hour = block.start.hour + block.start.minute / 60.0 %} + {% set duration_hours = block.duration / 3600.0 %} + {% set top_px = start_hour * 60 %} + {% set height_px = duration_hours * 60 %} + {% if height_px < 2 %}{% set height_px = 2 %}{% endif %} + + {% set task_hash = block.task_path|hash if block.task_path else 0 %} + {% set base_color_hue = task_hash % 360 %} + + {# Create gradient: active color on left (0% to active_ratio%), idle color on right #} + {% set active_pct = (block.active_ratio * 100)|int %} + {% set active_color = 'hsl(%d, 70%%, 50%%)'|format(base_color_hue) %} + {% set idle_color = 'hsl(%d, 30%%, 70%%)'|format(base_color_hue) %} + +
+
{{ block.task_path }}
+
{{ block.start.strftime('%H:%M') }} ({{ (block.duration // 60)|int }}m)
+
+ {{ block.task_path }}
+ {{ block.start.strftime('%H:%M') }} - {{ block.end.strftime('%H:%M') }}
+ Duration: {{ (block.duration // 60)|int }} minutes
+ Active: {{ (block.active_seconds // 60)|int }}m ({{ active_pct }}%)
+ Idle: {{ (block.idle_seconds // 60)|int }}m +
+
+ {% endif %} + {% endfor %} +
+
+ {% endfor %} +
+
+ +{% endblock content %} diff --git a/dmold/dmweb/templates/layout.html b/dmold/dmweb/templates/layout.html new file mode 100644 index 0000000..cc82ec6 --- /dev/null +++ b/dmold/dmweb/templates/layout.html @@ -0,0 +1,26 @@ + + + + + +{% if auto_refresh %} + +{% endif %} + +{% block head %} +{% endblock %} + + + + +{% block content %} +{% endblock %} + + + + + + + \ No newline at end of file diff --git a/dmold/dmweb/templates/main.html b/dmold/dmweb/templates/main.html new file mode 100644 index 0000000..8ac1bc0 --- /dev/null +++ b/dmold/dmweb/templates/main.html @@ -0,0 +1,64 @@ + + + + {% block head %} + {% endblock %} + + + + {% if auto_refresh %} + + {% endif %} + + + + + + +
+ {% block content %} + {% include 'main_content.html' %} + {% endblock %} +
+ + \ No newline at end of file diff --git a/dmold/dmweb/templates/main_content.html b/dmold/dmweb/templates/main_content.html new file mode 100644 index 0000000..651b1e6 --- /dev/null +++ b/dmold/dmweb/templates/main_content.html @@ -0,0 +1,23 @@ +{% if current_task_path and current_task_time %} +
+
{{ current_task_path }}
+
{{ current_task_time }}
+
+{% endif %} + + +{% for row in rows %} + {% if row["ws"] in ['Away', 'Other'] %} + {% set my_class = 'grey' %} + {% elif row["ws"] in ['Active', 'Idle'] %} + {% set my_class = 'blue' %} + {% else %} + {% set my_class = '' %} + {% endif %} + + + + +{% endfor %} + +
{{ row["ws"] }}{{ row["total"] }}
diff --git a/dmold/dmweb/templates/pages.html b/dmold/dmweb/templates/pages.html new file mode 100644 index 0000000..1d65793 --- /dev/null +++ b/dmold/dmweb/templates/pages.html @@ -0,0 +1,16 @@ +{% extends 'layout.html' %} + +{% block content %} + + + {% for row in rows %} + + + + +{% endfor %} +
{{ row["ws"] }}{{ row["total"] }}
+ + + +{% endblock content %} \ No newline at end of file diff --git a/dmold/dmweb/templates/switches_view.html b/dmold/dmweb/templates/switches_view.html new file mode 100644 index 0000000..b1a7ee2 --- /dev/null +++ b/dmold/dmweb/templates/switches_view.html @@ -0,0 +1,185 @@ +{% extends 'layout.html' %} + +{% block head %} + +{% endblock head %} + +{% block content %} + + + +
+ + {% if scope == 'daily' %} + {{ base_date.strftime('%Y-%m-%d %A') }} + {% elif scope == 'weekly' %} + Week of {{ start.strftime('%Y-%m-%d') }} + {% elif scope == 'monthly' %} + {{ base_date.strftime('%B %Y') }} + {% endif %} + +
+ +
+
+
+ Total Switches: + {{ switches|length }} +
+
+ Total Time: + {{ (switches|sum(attribute='delta') // 3600)|int }}h {{ ((switches|sum(attribute='delta') % 3600) // 60)|int }}m +
+
+
+ +

All Switches ({{ switches|length }})

+ +
+ {% for switch in switches %} + {% set is_active = switch.workspace in ['Plan', 'Think', 'Work'] %} + {% set task_hash = switch.task_path|hash if switch.task_path else 0 %} + {% set border_hue = task_hash % 360 %} + {% set border_color = 'hsl(%d, 70%%, 50%%)'|format(border_hue) %} + {% set bg_color = 'hsl(%d, 70%%, 95%%)'|format(border_hue) %} +
+
{{ switch.date.strftime('%m/%d %H:%M:%S') }}
+
{{ switch.workspace }}
+
{{ switch.task_path }}
+
+ {% if switch.delta >= 3600 %} + {{ (switch.delta // 3600)|int }}h {{ ((switch.delta % 3600) // 60)|int }}m + {% else %} + {{ (switch.delta // 60)|int }}m {{ (switch.delta % 60)|int }}s + {% endif %} +
+
+ {% endfor %} +
+ +{% if not switches %} +

No switches in this period

+{% endif %} + +{% endblock content %}