diff --git a/dmapp/dmweb/__init__.py b/dmapp/dmweb/__init__.py index de6735f..7f04fe5 100644 --- a/dmapp/dmweb/__init__.py +++ b/dmapp/dmweb/__init__.py @@ -9,4 +9,12 @@ def create_app(): 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/dmapp/dmweb/dm.py b/dmapp/dmweb/dm.py index bb1f6ce..a0e611f 100644 --- a/dmapp/dmweb/dm.py +++ b/dmapp/dmweb/dm.py @@ -2,7 +2,7 @@ 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 +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") @@ -12,6 +12,149 @@ 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): @@ -36,6 +179,18 @@ def index(task=None): 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): diff --git a/dmapp/dmweb/get_period_times.py b/dmapp/dmweb/get_period_times.py index 10a5450..a331655 100644 --- a/dmapp/dmweb/get_period_times.py +++ b/dmapp/dmweb/get_period_times.py @@ -148,6 +148,130 @@ def get_work_period_totals(start, end): 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 + task_path = None + if task_id: + task_doc = tasks.find_one({"task_id": task_id}) + task_path = task_doc["path"] if task_doc and "path" in task_doc else task_id + + current_block = { + "task_id": task_id, + "task_path": task_path or "No Task", + "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") + task_path = None + if task_id: + task_doc = tasks.find_one({"task_id": task_id}) + task_path = task_doc["path"] if task_doc and "path" in task_doc else task_id + + result.append({ + "workspace": switch["workspace"], + "task_id": task_id, + "task_path": task_path or "No Task", + "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 {} diff --git a/dmapp/dmweb/templates/calendar_view.html b/dmapp/dmweb/templates/calendar_view.html new file mode 100644 index 0000000..2ea9f33 --- /dev/null +++ b/dmapp/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/dmapp/dmweb/templates/switches_view.html b/dmapp/dmweb/templates/switches_view.html new file mode 100644 index 0000000..ace4d25 --- /dev/null +++ b/dmapp/dmweb/templates/switches_view.html @@ -0,0 +1,183 @@ +{% 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 %} +
+
{{ 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 %} diff --git a/gnome-extension/README.md b/gnome-extension/README.md new file mode 100644 index 0000000..535f95c --- /dev/null +++ b/gnome-extension/README.md @@ -0,0 +1,80 @@ +# Deskmeter GNOME Task Indicator + +A GNOME Shell extension that displays your current deskmeter task in the top panel, positioned to the left of the panel indicators. + +## Prerequisites + +- GNOME Shell (versions 40-47 supported) +- Deskmeter web server running on `http://localhost:10000` +- The `/api/current_task` endpoint must be accessible + +## Installation + +1. Copy the extension to your GNOME extensions directory: + +```bash +cp -r /home/mariano/wdir/dm/gnome-extension/deskmeter-indicator@local \ + ~/.local/share/gnome-shell/extensions/ +``` + +2. Restart GNOME Shell: + - On X11: Press `Alt+F2`, type `r`, and press Enter + - On Wayland: Log out and log back in + +3. Enable the extension: + +```bash +gnome-extensions enable deskmeter-indicator@local +``` + +Or use GNOME Extensions app (install with `sudo apt install gnome-shell-extension-prefs` if needed). + +## Configuration + +The extension polls the API every 3 seconds. You can adjust this in `extension.js`: + +```javascript +const UPDATE_INTERVAL = 3000; // milliseconds +``` + +The API URL is set to: + +```javascript +const DESKMETER_API_URL = 'http://localhost:10000/api/current_task'; +``` + +## Uninstallation + +```bash +gnome-extensions disable deskmeter-indicator@local +rm -rf ~/.local/share/gnome-shell/extensions/deskmeter-indicator@local +``` + +Then restart GNOME Shell. + +## Troubleshooting + +### Extension not showing + +1. Check if the extension is enabled: + ```bash + gnome-extensions list --enabled + ``` + +2. Check for errors: + ```bash + journalctl -f -o cat /usr/bin/gnome-shell + ``` + +### Shows "offline" or "error" + +- Ensure dmweb Flask server is running on port 10000 +- Test the API endpoint: + ```bash + curl http://localhost:10000/api/current_task + ``` + Should return JSON like: `{"task_id":"12345678","task_path":"work/default"}` + +### Task path too long + +The extension automatically truncates paths longer than 40 characters, showing only the last two segments with a `.../ ` prefix. diff --git a/gnome-extension/deskmeter-indicator@local/extension.js b/gnome-extension/deskmeter-indicator@local/extension.js new file mode 100644 index 0000000..73b4d4c --- /dev/null +++ b/gnome-extension/deskmeter-indicator@local/extension.js @@ -0,0 +1,101 @@ +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import Clutter from 'gi://Clutter'; + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; + +const DESKMETER_API_URL = 'http://localhost:10000/api/current_task'; +const UPDATE_INTERVAL = 3000; // 3 seconds + +const TaskIndicator = GObject.registerClass( +class TaskIndicator extends PanelMenu.Button { + _init() { + super._init(0.0, 'Deskmeter Task Indicator', false); + + // Create label for task display + this._label = new St.Label({ + text: 'loading...', + y_align: Clutter.ActorAlign.CENTER, + style_class: 'deskmeter-task-label' + }); + + this.add_child(this._label); + + // Start periodic updates + this._updateTask(); + this._timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, UPDATE_INTERVAL, () => { + this._updateTask(); + return GLib.SOURCE_CONTINUE; + }); + } + + _updateTask() { + try { + // Create HTTP request + let file = Gio.File.new_for_uri(DESKMETER_API_URL); + file.load_contents_async(null, (source, result) => { + try { + let [success, contents] = source.load_contents_finish(result); + if (success) { + let decoder = new TextDecoder('utf-8'); + let data = JSON.parse(decoder.decode(contents)); + + // Update label with task path + let displayText = data.task_path || 'no task'; + + // Optionally truncate long paths + if (displayText.length > 40) { + let parts = displayText.split('/'); + if (parts.length > 2) { + displayText = '.../' + parts.slice(-2).join('/'); + } else { + displayText = displayText.substring(0, 37) + '...'; + } + } + + this._label.set_text(displayText); + } + } catch (e) { + this._label.set_text('error'); + logError(e, 'Failed to parse deskmeter response'); + } + }); + } catch (e) { + this._label.set_text('offline'); + logError(e, 'Failed to fetch deskmeter task'); + } + } + + destroy() { + if (this._timeout) { + GLib.source_remove(this._timeout); + this._timeout = null; + } + super.destroy(); + } +}); + +export default class Extension { + constructor() { + this._indicator = null; + } + + enable() { + this._indicator = new TaskIndicator(); + + // Add to panel - position after workspace indicator + // Panel boxes: left, center, right + // We'll add it to the left panel, after other items + Main.panel.addToStatusArea('deskmeter-task-indicator', this._indicator, 1, 'left'); + } + + disable() { + if (this._indicator) { + this._indicator.destroy(); + this._indicator = null; + } + } +} diff --git a/gnome-extension/deskmeter-indicator@local/metadata.json b/gnome-extension/deskmeter-indicator@local/metadata.json new file mode 100644 index 0000000..509a07b --- /dev/null +++ b/gnome-extension/deskmeter-indicator@local/metadata.json @@ -0,0 +1,17 @@ +{ + "name": "Deskmeter Task Indicator", + "description": "Displays current deskmeter task in GNOME panel", + "uuid": "deskmeter-indicator@local", + "shell-version": [ + "40", + "41", + "42", + "43", + "44", + "45", + "46", + "47" + ], + "url": "", + "version": 1 +} diff --git a/gnome-extension/deskmeter-indicator@local/stylesheet.css b/gnome-extension/deskmeter-indicator@local/stylesheet.css new file mode 100644 index 0000000..036f34b --- /dev/null +++ b/gnome-extension/deskmeter-indicator@local/stylesheet.css @@ -0,0 +1,5 @@ +.deskmeter-task-label { + font-weight: normal; + padding: 0 8px; + color: #ffffff; +} diff --git a/gnome-extension/install.sh b/gnome-extension/install.sh new file mode 100644 index 0000000..7d2cd6b --- /dev/null +++ b/gnome-extension/install.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Install Deskmeter GNOME Task Indicator + +EXTENSION_DIR="$HOME/.local/share/gnome-shell/extensions" +EXTENSION_NAME="deskmeter-indicator@local" +SOURCE_DIR="$(cd "$(dirname "$0")" && pwd)/$EXTENSION_NAME" + +echo "Installing Deskmeter Task Indicator..." + +# Create extensions directory if it doesn't exist +mkdir -p "$EXTENSION_DIR" + +# Copy extension files +cp -r "$SOURCE_DIR" "$EXTENSION_DIR/" + +echo "Extension copied to $EXTENSION_DIR/$EXTENSION_NAME" +echo "" +echo "Next steps:" +echo "1. Restart GNOME Shell:" +echo " - On X11: Press Alt+F2, type 'r', press Enter" +echo " - On Wayland: Log out and log back in" +echo "" +echo "2. Enable the extension:" +echo " gnome-extensions enable $EXTENSION_NAME" +echo "" +echo "Make sure dmweb is running on http://localhost:10000"