diff --git a/dmapp/dmcore/main.py b/dmapp/dmcore/main.py index 4a2393c..aaafa87 100644 --- a/dmapp/dmcore/main.py +++ b/dmapp/dmcore/main.py @@ -4,11 +4,11 @@ import os import subprocess import time from pprint import pprint +from zoneinfo import ZoneInfo import state import task from config import logger, switches -from zoneinfo import ZoneInfo desktops = ("Plan", "Think", "Work", "Other", "Away", "Work", "Work", "Work") unlabeled = "Away" @@ -39,22 +39,18 @@ def now(): return datetime.datetime.now(ZoneInfo(cfg["timezone"])) -def handle_task_file_changes(current_task): - """Check if task file changed and update task if needed. Returns (new_task, file_changed)""" +def handle_task_file_changes(): + """Check if task file changed and sync definitions to DB. Does not change current task.""" current_mtime = state.retrieve("current").get("filetime") file_mtime = task.get_file_mtime(None) if current_mtime != file_mtime: - task_id = task.read_and_extract(None) - logger.debug(f"task_id:{task_id}") task.file_to_db(None) - if task_id != current_task: - state.save("current", task=task_id) - current_task = task_id + state.save("current", filetime=file_mtime) + logger.info("Task file changed, definitions synced to DB") + return True # File changed - return current_task, True # File changed - - return current_task, False # No change + return False # No change def update_workspace_state(): @@ -65,14 +61,19 @@ def update_workspace_state(): def enforce_desktop_task(current_workspace, work_desktop_tasks, current_task): - """Enforce assigned task for work desktops""" - if current_workspace in work_desktop_tasks and work_desktop_tasks[current_workspace]: + """Enforce assigned task for work desktops. Updates MongoDB state only, never writes to file.""" + if ( + current_workspace in work_desktop_tasks + and work_desktop_tasks[current_workspace] + ): assigned_task = work_desktop_tasks[current_workspace] if current_task != assigned_task: current_task = assigned_task state.save("current", task=current_task) - task.db_to_file_as_is(None) + logger.debug( + f"Enforced task {assigned_task} for workspace {current_workspace}" + ) return current_task @@ -121,8 +122,9 @@ def desktop(workspace_index): return unlabeled -task.read_and_extract(None) +# Sync task definitions from file to DB on startup task.file_to_db(None) +state.save("current", filetime=task.get_file_mtime(None)) current_workspace = active_workspace() current_task = state.retrieve("current").get("task") @@ -144,18 +146,21 @@ while True: # Load work_desktop_tasks from state work_desktop_tasks = state.retrieve_desktop_state() - # Handle task file changes - current_task, file_changed = handle_task_file_changes(current_task) + # Sync task definitions if file changed (does not change current task) + handle_task_file_changes() - # Update current task and workspace + # Get current state current_task = state.retrieve("current").get("task") current_workspace = update_workspace_state() - # Enforce desktop task assignments (but skip if file just changed - user's manual change takes priority) - if not file_changed: - current_task = enforce_desktop_task(current_workspace, work_desktop_tasks, current_task) + # Enforce work desktop task assignments + current_task = enforce_desktop_task( + current_workspace, work_desktop_tasks, current_task + ) # Track workspace switches - last_switch_time = track_workspace_switch(current_workspace, current_task, last_switch_time) + last_switch_time = track_workspace_switch( + current_workspace, current_task, last_switch_time + ) time.sleep(2) diff --git a/dmapp/dmcore/task.py b/dmapp/dmcore/task.py index 6fe1ac4..0cf20e0 100644 --- a/dmapp/dmcore/task.py +++ b/dmapp/dmcore/task.py @@ -1,9 +1,7 @@ import datetime -import re from pathlib import Path from typing import Optional -import state from bson import ObjectId from config import logger, tasks @@ -59,7 +57,13 @@ def file_to_db(filepath: str): if task_id: tasks.update_one( {"task_id": task_id}, - {"$set": {"path": full_path, "task_id": task_id, "historic": False}}, + { + "$set": { + "path": full_path, + "task_id": task_id, + "historic": False, + } + }, upsert=True, ) elif full_path not in seen_paths: @@ -68,96 +72,10 @@ def file_to_db(filepath: str): seen_paths.add(full_path) -def format_task_line( - path_parts: list, indent_level: int, task_id: str, current_task: str -) -> str: - line = " " * (4 * indent_level) + path_parts[-1] - if task_id: - padding = max(1, 64 - len(line)) - line = f"{line}{' ' * padding}|{task_id}" - if task_id == current_task: - line += " *" - return line - - -def db_to_file_as_is(filepath: str): - """Write tasks from MongoDB to file exactly as they were read.""" - if filepath is None: - filepath = task_file - - current_task = state.retrieve("current").get("task") - all_tasks = list(tasks.find()) - - with open(filepath, "w") as f: - for task in all_tasks: - if not task["path"]: - f.write("\n") - continue - - path_parts = task["path"].split("/") - line = format_task_line( - path_parts, len(path_parts) - 1, task.get("task_id", ""), current_task - ) - f.write(f"{line}\n") - - def get_all_tasks(prefix): return list(tasks.find({"path": {"$ne": ""}}).sort("path", 1)) -def db_to_file_consolidated(filepath: str): - """Write tasks from MongoDB to file as a consolidated tree.""" - current_task = state.retrieve("current").get("task") - all_tasks = list(tasks.find({"path": {"$ne": ""}}).sort("path", 1)) - - with open(filepath, "w") as f: - prev_parts = [] - for task in all_tasks: - path_parts = task["path"].split("/") - common = 0 - for i, (prev, curr) in enumerate(zip(prev_parts, path_parts)): - if prev != curr: - break - common = i + 1 - - for i, part in enumerate(path_parts[common:], common): - path_segment = path_parts[: i + 1] - task_id = task.get("task_id", "") if path_segment == path_parts else "" - line = format_task_line([part], i, task_id, current_task) - f.write(f"{line}\n") - - prev_parts = path_parts - - -def extract(line: str) -> Optional[str]: - """Extract task ID if line ends with * and has a valid ID.""" - line = line.rstrip() - if line.endswith("*"): - pipe_index = line.find("|") - if pipe_index != -1: - # Extract everything between | and * and strip spaces - id_part = line[pipe_index + 1 : -1].strip() - if len(id_part) == 8: - return id_part - return None - - -def read_and_extract(filepath: str) -> Optional[str]: - """Read file and update state if current task is found.""" - if filepath is None: - filepath = task_file - - mtime = get_file_mtime(filepath) - state.save("current", filetime=mtime) - - with open(filepath, "r") as file: - for line in file: - task_id = extract(line) - if task_id: - return task_id - return None - - def get_file_mtime(filepath: str) -> str: """Get file modification time as ISO format string.""" if filepath is None: diff --git a/dmapp/dmos/gnome-extension/deskmeter-indicator@local/extension.js b/dmapp/dmos/gnome-extension/deskmeter-indicator@local/extension.js index 979f36a..cf0244b 100644 --- a/dmapp/dmos/gnome-extension/deskmeter-indicator@local/extension.js +++ b/dmapp/dmos/gnome-extension/deskmeter-indicator@local/extension.js @@ -1,204 +1,235 @@ -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 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'; +import * as Main from "resource:///org/gnome/shell/ui/main.js"; +import * as PanelMenu from "resource:///org/gnome/shell/ui/panelMenu.js"; // Try common ports - worktree (10001) first, then default (10000) const DEFAULT_PORTS = [10001, 10000]; const DEBOUNCE_DELAY = 2200; // Wait 2.2s after workspace switch (dmcore polls every 2s) +const PERIODIC_REFRESH_SECONDS = 10; // Periodic refresh as backup to catch stale data let DESKMETER_API_URL = null; const TaskIndicator = GObject.registerClass( -class TaskIndicator extends PanelMenu.Button { + class TaskIndicator extends PanelMenu.Button { _init() { - super._init(0.0, 'Deskmeter Task Indicator', false); + super._init(0.0, "Deskmeter Task Indicator", false); - // Create label for task display - this._label = new St.Label({ - text: 'detecting...', - y_align: Clutter.ActorAlign.CENTER, - style_class: 'deskmeter-task-label' - }); + // Create label for task display + this._label = new St.Label({ + text: "detecting...", + y_align: Clutter.ActorAlign.CENTER, + style_class: "deskmeter-task-label", + }); - this.add_child(this._label); + this.add_child(this._label); - this._debounceTimeout = null; - this._workspaceManager = global.workspace_manager; - this._apiUrl = null; + this._debounceTimeout = null; + this._periodicTimeout = null; + this._workspaceManager = global.workspace_manager; + this._apiUrl = null; - // Connect to workspace switch signal - this._workspaceSwitchedId = this._workspaceManager.connect( - 'workspace-switched', - this._onWorkspaceSwitched.bind(this) - ); + // Connect to workspace switch signal + this._workspaceSwitchedId = this._workspaceManager.connect( + "workspace-switched", + this._onWorkspaceSwitched.bind(this), + ); - // Detect API port, then start updates - this._detectApiPort(); + // Detect API port, then start updates + this._detectApiPort(); + } + + _startPeriodicRefresh() { + // Periodic refresh as backup to catch stale data + this._periodicTimeout = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + PERIODIC_REFRESH_SECONDS, + () => { + this._updateTask(); + return GLib.SOURCE_CONTINUE; + }, + ); } _detectApiPort() { - // Try each port in sequence - this._tryNextPort(0); + // Try each port in sequence + this._tryNextPort(0); } _tryNextPort(index) { - if (index >= DEFAULT_PORTS.length) { - // No ports responded, use default and let it show "offline" - this._apiUrl = `http://localhost:${DEFAULT_PORTS[DEFAULT_PORTS.length - 1]}/api/current_task`; - this._scheduleUpdate(); - return; - } + if (index >= DEFAULT_PORTS.length) { + // No ports responded, use default and let it show "offline" + this._apiUrl = `http://localhost:${DEFAULT_PORTS[DEFAULT_PORTS.length - 1]}/api/current_task`; + this._scheduleUpdate(); + this._startPeriodicRefresh(); + return; + } - const port = DEFAULT_PORTS[index]; - const url = `http://localhost:${port}/api/current_task`; + const port = DEFAULT_PORTS[index]; + const url = `http://localhost:${port}/api/current_task`; - try { - let file = Gio.File.new_for_uri(url); - file.load_contents_async(null, (source, result) => { - try { - let [success, contents] = source.load_contents_finish(result); - if (success) { - // Port responded, use it - this._apiUrl = url; - this._scheduleUpdate(); - return; - } - } catch (e) { - // This port failed, try next - this._tryNextPort(index + 1); - } - }); - } catch (e) { + try { + let file = Gio.File.new_for_uri(url); + file.load_contents_async(null, (source, result) => { + try { + let [success, contents] = source.load_contents_finish(result); + if (success) { + // Port responded, use it + this._apiUrl = url; + this._scheduleUpdate(); + this._startPeriodicRefresh(); + return; + } + } catch (e) { // This port failed, try next this._tryNextPort(index + 1); - } + } + }); + } catch (e) { + // This port failed, try next + this._tryNextPort(index + 1); + } } _onWorkspaceSwitched() { - // Debounce updates - dmcore takes ~2 seconds to detect and update - // We wait a bit to ensure the task has been updated in MongoDB - this._scheduleUpdate(); + // Debounce updates - dmcore takes ~2 seconds to detect and update + // We wait a bit to ensure the task has been updated in MongoDB + this._scheduleUpdate(); } _scheduleUpdate() { - // Clear any pending update - if (this._debounceTimeout) { - GLib.source_remove(this._debounceTimeout); - } + // Clear any pending update + if (this._debounceTimeout) { + GLib.source_remove(this._debounceTimeout); + } - // Schedule new update - this._debounceTimeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DEBOUNCE_DELAY, () => { - this._updateTask(); - this._debounceTimeout = null; - return GLib.SOURCE_REMOVE; - }); + // Schedule new update + this._debounceTimeout = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + DEBOUNCE_DELAY, + () => { + this._updateTask(); + this._debounceTimeout = null; + return GLib.SOURCE_REMOVE; + }, + ); } _updateTask() { - if (!this._apiUrl) { - this._label.set_text('detecting...'); - return; - } + if (!this._apiUrl) { + this._label.set_text("detecting..."); + return; + } - try { - // Create HTTP request - let file = Gio.File.new_for_uri(this._apiUrl); - 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)); + try { + // Create HTTP request + let file = Gio.File.new_for_uri(this._apiUrl); + 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'; + // 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'); + // 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) + "..."; } - }); - } catch (e) { - this._label.set_text('offline'); - logError(e, 'Failed to fetch deskmeter task'); - } + } + + 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() { - try { - if (this._debounceTimeout) { - GLib.source_remove(this._debounceTimeout); - this._debounceTimeout = null; - } - - if (this._workspaceSwitchedId) { - this._workspaceManager.disconnect(this._workspaceSwitchedId); - this._workspaceSwitchedId = null; - } - - super.destroy(); - } catch (e) { - // Log error but don't crash GNOME Shell - logError(e, 'Failed to destroy TaskIndicator'); + try { + if (this._debounceTimeout) { + GLib.source_remove(this._debounceTimeout); + this._debounceTimeout = null; } + + if (this._periodicTimeout) { + GLib.source_remove(this._periodicTimeout); + this._periodicTimeout = null; + } + + if (this._workspaceSwitchedId) { + this._workspaceManager.disconnect(this._workspaceSwitchedId); + this._workspaceSwitchedId = null; + } + + super.destroy(); + } catch (e) { + // Log error but don't crash GNOME Shell + logError(e, "Failed to destroy TaskIndicator"); + } } -}); + }, +); export default class Extension { - constructor() { + constructor() { + this._indicator = null; + } + + enable() { + try { + 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", + ); + } catch (e) { + // Log error but don't crash GNOME Shell + logError(e, "Failed to enable Deskmeter extension"); + + // Clean up if partially initialized + if (this._indicator) { + try { + this._indicator.destroy(); + } catch (destroyError) { + logError(destroyError, "Failed to cleanup indicator"); + } this._indicator = null; + } } + } - enable() { - try { - 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'); - } catch (e) { - // Log error but don't crash GNOME Shell - logError(e, 'Failed to enable Deskmeter extension'); - - // Clean up if partially initialized - if (this._indicator) { - try { - this._indicator.destroy(); - } catch (destroyError) { - logError(destroyError, 'Failed to cleanup indicator'); - } - this._indicator = null; - } - } - } - - disable() { - try { - if (this._indicator) { - this._indicator.destroy(); - this._indicator = null; - } - } catch (e) { - // Log error but don't crash GNOME Shell - logError(e, 'Failed to disable Deskmeter extension'); - this._indicator = null; - } + disable() { + try { + if (this._indicator) { + this._indicator.destroy(); + this._indicator = null; + } + } catch (e) { + // Log error but don't crash GNOME Shell + logError(e, "Failed to disable Deskmeter extension"); + this._indicator = null; } + } } diff --git a/dmapp/dmweb/dmcal.py b/dmapp/dmweb/dmcal.py index cb38cf5..bb88e97 100644 --- a/dmapp/dmweb/dmcal.py +++ b/dmapp/dmweb/dmcal.py @@ -8,15 +8,12 @@ from flask import Blueprint, render_template from .dm import dmbp from .get_period_times import ( get_period_totals, - read_and_extract, - task_file, + get_work_period_totals, task_or_none, timezone, - get_work_period_totals, ) - class DMHTMLCalendar(calendar.HTMLCalendar): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -148,7 +145,9 @@ def workmonth(month=None, year=None): cal.setcalmonth(usemonth) cal.setcalyear(useyear) - return render_template("calendar.html", content=cal.formatmonth(useyear, usemonth), auto_refresh=False) + return render_template( + "calendar.html", content=cal.formatmonth(useyear, usemonth), auto_refresh=False + ) @dmbp.route("/month") @@ -174,4 +173,6 @@ def month(month=None, year=None, task=None): cal.setcalmonth(usemonth) cal.setcalyear(useyear) - return render_template("calendar.html", content=cal.formatmonth(useyear, usemonth), auto_refresh=True) + return render_template( + "calendar.html", content=cal.formatmonth(useyear, usemonth), auto_refresh=True + ) diff --git a/dmapp/dmweb/get_period_times.py b/dmapp/dmweb/get_period_times.py index 66cca31..b7f50ee 100644 --- a/dmapp/dmweb/get_period_times.py +++ b/dmapp/dmweb/get_period_times.py @@ -1,9 +1,9 @@ from collections import Counter, defaultdict from datetime import datetime, timedelta from pathlib import Path +from zoneinfo import ZoneInfo from pymongo import MongoClient -from zoneinfo import ZoneInfo timezone = ZoneInfo("America/Argentina/Buenos_Aires") utctz = ZoneInfo("UTC") @@ -38,8 +38,8 @@ def parse_task_line(line): 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("*"): + """Search task directory files (recursively) for a task ID and load it into task_history.""" + for task_filepath in task_dir.glob("**/*"): if not task_filepath.is_file(): continue @@ -65,12 +65,14 @@ def load_task_from_files(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 + { + "$set": { + "path": full_path, + "task_id": task_id, + "source_file": task_filepath.name, + } + }, + upsert=True, ) return full_path except: @@ -134,15 +136,10 @@ def get_task_time_seconds(start, end, task_id, workspaces=None): "$match": { "date": {"$gte": start, "$lte": end}, "task": task_id, - "workspace": {"$in": workspaces} + "workspace": {"$in": workspaces}, } }, - { - "$group": { - "_id": None, - "total_seconds": {"$sum": "$delta"} - } - } + {"$group": {"_id": None, "total_seconds": {"$sum": "$delta"}}}, ] result = list(switches.aggregate(pipeline)) @@ -154,12 +151,8 @@ def get_task_time_seconds(start, end, task_id, workspaces=None): def task_or_none(task=None): - if not task: - task = read_and_extract(task_file) - if task == "all": task = None - return task @@ -180,24 +173,6 @@ def convert_seconds(seconds, use_days=False): 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 @@ -206,15 +181,10 @@ def get_work_period_totals(start, end): "$match": { "date": {"$gte": start, "$lte": end}, "workspace": {"$in": ["Plan", "Think", "Work"]}, - "task": {"$exists": True, "$ne": None} + "task": {"$exists": True, "$ne": None}, } }, - { - "$group": { - "_id": "$task", - "total_seconds": {"$sum": "$delta"} - } - } + {"$group": {"_id": "$task", "total_seconds": {"$sum": "$delta"}}}, ] results = list(switches.aggregate(pipeline)) @@ -228,17 +198,18 @@ def get_work_period_totals(start, end): # Get task path with history fallback task_path = get_task_path(task_id) - combined_rows.append({ - "ws": task_path, - "total": convert_seconds(total_seconds) - }) + 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, grid_hours=1): +def get_task_blocks_calendar( + start, end, task=None, min_block_seconds=300, grid_hours=1 +): """ Get task blocks for calendar-style visualization, aggregated by time grid. Shows all tasks worked on during each grid period, with overlapping blocks. @@ -263,7 +234,7 @@ def get_task_blocks_calendar(start, end, task=None, min_block_seconds=300, grid_ match_query = { "date": {"$gte": start, "$lte": end}, - "workspace": {"$in": ["Plan", "Think", "Work"]} # Only active workspaces + "workspace": {"$in": ["Plan", "Think", "Work"]}, # Only active workspaces } if task_query: match_query["task"] = task_query @@ -291,13 +262,14 @@ def get_task_blocks_calendar(start, end, task=None, min_block_seconds=300, grid_ while remaining_duration > 0 and current_time < switch_end: # Calculate grid period start (hour rounded down to grid_hours) grid_hour = (current_time.hour // grid_hours) * grid_hours - grid_start = current_time.replace(hour=grid_hour, minute=0, second=0, microsecond=0) + grid_start = current_time.replace( + hour=grid_hour, minute=0, second=0, microsecond=0 + ) grid_end = grid_start + timedelta(hours=grid_hours) # Time in this grid period time_in_grid = min( - (grid_end - current_time).total_seconds(), - remaining_duration + (grid_end - current_time).total_seconds(), remaining_duration ) key = (current_time.date(), grid_hour, task_id) @@ -316,19 +288,23 @@ def get_task_blocks_calendar(start, end, task=None, min_block_seconds=300, grid_ blocks = [] for (date, grid_hour, task_id), data in grid_task_time.items(): if data["duration"] >= min_block_seconds: - grid_start = datetime(date.year, date.month, date.day, grid_hour, 0, 0, tzinfo=timezone) + grid_start = datetime( + date.year, date.month, date.day, grid_hour, 0, 0, tzinfo=timezone + ) - blocks.append({ - "task_id": task_id, - "task_path": data["task_path"], - "start": grid_start, - "end": grid_start + timedelta(seconds=data["duration"]), - "hour": grid_hour, - "duration": int(data["duration"]), - "active_seconds": int(data["duration"]), - "idle_seconds": 0, - "active_ratio": 1.0 - }) + blocks.append( + { + "task_id": task_id, + "task_path": data["task_path"], + "start": grid_start, + "end": grid_start + timedelta(seconds=data["duration"]), + "hour": grid_hour, + "duration": int(data["duration"]), + "active_seconds": int(data["duration"]), + "idle_seconds": 0, + "active_ratio": 1.0, + } + ) return sorted(blocks, key=lambda x: (x["start"], x["task_path"])) @@ -360,13 +336,15 @@ def get_raw_switches(start, end, task=None): # 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"] - }) + 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