more solid task updating, no need to stop the main loop to avoid race conditions

This commit is contained in:
buenosairesam
2026-01-22 01:38:47 -03:00
parent 88caa3dc96
commit bf7bcbc37a
5 changed files with 277 additions and 344 deletions

View File

@@ -4,11 +4,11 @@ import os
import subprocess import subprocess
import time import time
from pprint import pprint from pprint import pprint
from zoneinfo import ZoneInfo
import state import state
import task import task
from config import logger, switches from config import logger, switches
from zoneinfo import ZoneInfo
desktops = ("Plan", "Think", "Work", "Other", "Away", "Work", "Work", "Work") desktops = ("Plan", "Think", "Work", "Other", "Away", "Work", "Work", "Work")
unlabeled = "Away" unlabeled = "Away"
@@ -39,22 +39,18 @@ def now():
return datetime.datetime.now(ZoneInfo(cfg["timezone"])) return datetime.datetime.now(ZoneInfo(cfg["timezone"]))
def handle_task_file_changes(current_task): def handle_task_file_changes():
"""Check if task file changed and update task if needed. Returns (new_task, file_changed)""" """Check if task file changed and sync definitions to DB. Does not change current task."""
current_mtime = state.retrieve("current").get("filetime") current_mtime = state.retrieve("current").get("filetime")
file_mtime = task.get_file_mtime(None) file_mtime = task.get_file_mtime(None)
if current_mtime != file_mtime: if current_mtime != file_mtime:
task_id = task.read_and_extract(None)
logger.debug(f"task_id:{task_id}")
task.file_to_db(None) task.file_to_db(None)
if task_id != current_task: state.save("current", filetime=file_mtime)
state.save("current", task=task_id) logger.info("Task file changed, definitions synced to DB")
current_task = task_id return True # File changed
return current_task, True # File changed return False # No change
return current_task, False # No change
def update_workspace_state(): def update_workspace_state():
@@ -65,14 +61,19 @@ def update_workspace_state():
def enforce_desktop_task(current_workspace, work_desktop_tasks, current_task): def enforce_desktop_task(current_workspace, work_desktop_tasks, current_task):
"""Enforce assigned task for work desktops""" """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]: if (
current_workspace in work_desktop_tasks
and work_desktop_tasks[current_workspace]
):
assigned_task = work_desktop_tasks[current_workspace] assigned_task = work_desktop_tasks[current_workspace]
if current_task != assigned_task: if current_task != assigned_task:
current_task = assigned_task current_task = assigned_task
state.save("current", task=current_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 return current_task
@@ -121,8 +122,9 @@ def desktop(workspace_index):
return unlabeled return unlabeled
task.read_and_extract(None) # Sync task definitions from file to DB on startup
task.file_to_db(None) task.file_to_db(None)
state.save("current", filetime=task.get_file_mtime(None))
current_workspace = active_workspace() current_workspace = active_workspace()
current_task = state.retrieve("current").get("task") current_task = state.retrieve("current").get("task")
@@ -144,18 +146,21 @@ while True:
# Load work_desktop_tasks from state # Load work_desktop_tasks from state
work_desktop_tasks = state.retrieve_desktop_state() work_desktop_tasks = state.retrieve_desktop_state()
# Handle task file changes # Sync task definitions if file changed (does not change current task)
current_task, file_changed = handle_task_file_changes(current_task) handle_task_file_changes()
# Update current task and workspace # Get current state
current_task = state.retrieve("current").get("task") current_task = state.retrieve("current").get("task")
current_workspace = update_workspace_state() current_workspace = update_workspace_state()
# Enforce desktop task assignments (but skip if file just changed - user's manual change takes priority) # Enforce work desktop task assignments
if not file_changed: current_task = enforce_desktop_task(
current_task = enforce_desktop_task(current_workspace, work_desktop_tasks, current_task) current_workspace, work_desktop_tasks, current_task
)
# Track workspace switches # 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) time.sleep(2)

View File

@@ -1,9 +1,7 @@
import datetime import datetime
import re
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import state
from bson import ObjectId from bson import ObjectId
from config import logger, tasks from config import logger, tasks
@@ -59,7 +57,13 @@ def file_to_db(filepath: str):
if task_id: if task_id:
tasks.update_one( tasks.update_one(
{"task_id": task_id}, {"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, upsert=True,
) )
elif full_path not in seen_paths: elif full_path not in seen_paths:
@@ -68,96 +72,10 @@ def file_to_db(filepath: str):
seen_paths.add(full_path) 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): def get_all_tasks(prefix):
return list(tasks.find({"path": {"$ne": ""}}).sort("path", 1)) 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: def get_file_mtime(filepath: str) -> str:
"""Get file modification time as ISO format string.""" """Get file modification time as ISO format string."""
if filepath is None: if filepath is None:

View File

@@ -1,204 +1,235 @@
import GObject from 'gi://GObject'; import GObject from "gi://GObject";
import St from 'gi://St'; import St from "gi://St";
import Gio from 'gi://Gio'; import Gio from "gi://Gio";
import GLib from 'gi://GLib'; import GLib from "gi://GLib";
import Clutter from 'gi://Clutter'; import Clutter from "gi://Clutter";
import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as Main from "resource:///org/gnome/shell/ui/main.js";
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; import * as PanelMenu from "resource:///org/gnome/shell/ui/panelMenu.js";
// Try common ports - worktree (10001) first, then default (10000) // Try common ports - worktree (10001) first, then default (10000)
const DEFAULT_PORTS = [10001, 10000]; const DEFAULT_PORTS = [10001, 10000];
const DEBOUNCE_DELAY = 2200; // Wait 2.2s after workspace switch (dmcore polls every 2s) 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; let DESKMETER_API_URL = null;
const TaskIndicator = GObject.registerClass( const TaskIndicator = GObject.registerClass(
class TaskIndicator extends PanelMenu.Button { class TaskIndicator extends PanelMenu.Button {
_init() { _init() {
super._init(0.0, 'Deskmeter Task Indicator', false); super._init(0.0, "Deskmeter Task Indicator", false);
// Create label for task display // Create label for task display
this._label = new St.Label({ this._label = new St.Label({
text: 'detecting...', text: "detecting...",
y_align: Clutter.ActorAlign.CENTER, y_align: Clutter.ActorAlign.CENTER,
style_class: 'deskmeter-task-label' style_class: "deskmeter-task-label",
}); });
this.add_child(this._label); this.add_child(this._label);
this._debounceTimeout = null; this._debounceTimeout = null;
this._workspaceManager = global.workspace_manager; this._periodicTimeout = null;
this._apiUrl = null; this._workspaceManager = global.workspace_manager;
this._apiUrl = null;
// Connect to workspace switch signal // Connect to workspace switch signal
this._workspaceSwitchedId = this._workspaceManager.connect( this._workspaceSwitchedId = this._workspaceManager.connect(
'workspace-switched', "workspace-switched",
this._onWorkspaceSwitched.bind(this) this._onWorkspaceSwitched.bind(this),
); );
// Detect API port, then start updates // Detect API port, then start updates
this._detectApiPort(); 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() { _detectApiPort() {
// Try each port in sequence // Try each port in sequence
this._tryNextPort(0); this._tryNextPort(0);
} }
_tryNextPort(index) { _tryNextPort(index) {
if (index >= DEFAULT_PORTS.length) { if (index >= DEFAULT_PORTS.length) {
// No ports responded, use default and let it show "offline" // No ports responded, use default and let it show "offline"
this._apiUrl = `http://localhost:${DEFAULT_PORTS[DEFAULT_PORTS.length - 1]}/api/current_task`; this._apiUrl = `http://localhost:${DEFAULT_PORTS[DEFAULT_PORTS.length - 1]}/api/current_task`;
this._scheduleUpdate(); this._scheduleUpdate();
return; this._startPeriodicRefresh();
} return;
}
const port = DEFAULT_PORTS[index]; const port = DEFAULT_PORTS[index];
const url = `http://localhost:${port}/api/current_task`; const url = `http://localhost:${port}/api/current_task`;
try { try {
let file = Gio.File.new_for_uri(url); let file = Gio.File.new_for_uri(url);
file.load_contents_async(null, (source, result) => { file.load_contents_async(null, (source, result) => {
try { try {
let [success, contents] = source.load_contents_finish(result); let [success, contents] = source.load_contents_finish(result);
if (success) { if (success) {
// Port responded, use it // Port responded, use it
this._apiUrl = url; this._apiUrl = url;
this._scheduleUpdate(); this._scheduleUpdate();
return; this._startPeriodicRefresh();
} return;
} catch (e) { }
// This port failed, try next } catch (e) {
this._tryNextPort(index + 1);
}
});
} catch (e) {
// This port failed, try next // This port failed, try next
this._tryNextPort(index + 1); this._tryNextPort(index + 1);
} }
});
} catch (e) {
// This port failed, try next
this._tryNextPort(index + 1);
}
} }
_onWorkspaceSwitched() { _onWorkspaceSwitched() {
// Debounce updates - dmcore takes ~2 seconds to detect and update // Debounce updates - dmcore takes ~2 seconds to detect and update
// We wait a bit to ensure the task has been updated in MongoDB // We wait a bit to ensure the task has been updated in MongoDB
this._scheduleUpdate(); this._scheduleUpdate();
} }
_scheduleUpdate() { _scheduleUpdate() {
// Clear any pending update // Clear any pending update
if (this._debounceTimeout) { if (this._debounceTimeout) {
GLib.source_remove(this._debounceTimeout); GLib.source_remove(this._debounceTimeout);
} }
// Schedule new update // Schedule new update
this._debounceTimeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DEBOUNCE_DELAY, () => { this._debounceTimeout = GLib.timeout_add(
this._updateTask(); GLib.PRIORITY_DEFAULT,
this._debounceTimeout = null; DEBOUNCE_DELAY,
return GLib.SOURCE_REMOVE; () => {
}); this._updateTask();
this._debounceTimeout = null;
return GLib.SOURCE_REMOVE;
},
);
} }
_updateTask() { _updateTask() {
if (!this._apiUrl) { if (!this._apiUrl) {
this._label.set_text('detecting...'); this._label.set_text("detecting...");
return; return;
} }
try { try {
// Create HTTP request // Create HTTP request
let file = Gio.File.new_for_uri(this._apiUrl); let file = Gio.File.new_for_uri(this._apiUrl);
file.load_contents_async(null, (source, result) => { file.load_contents_async(null, (source, result) => {
try { try {
let [success, contents] = source.load_contents_finish(result); let [success, contents] = source.load_contents_finish(result);
if (success) { if (success) {
let decoder = new TextDecoder('utf-8'); let decoder = new TextDecoder("utf-8");
let data = JSON.parse(decoder.decode(contents)); let data = JSON.parse(decoder.decode(contents));
// Update label with task path // Update label with task path
let displayText = data.task_path || 'no task'; let displayText = data.task_path || "no task";
// Optionally truncate long paths // Optionally truncate long paths
if (displayText.length > 40) { if (displayText.length > 40) {
let parts = displayText.split('/'); let parts = displayText.split("/");
if (parts.length > 2) { if (parts.length > 2) {
displayText = '.../' + parts.slice(-2).join('/'); displayText = ".../" + parts.slice(-2).join("/");
} else { } else {
displayText = displayText.substring(0, 37) + '...'; 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'); this._label.set_text(displayText);
logError(e, 'Failed to fetch deskmeter task'); }
} } 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() { destroy() {
try { try {
if (this._debounceTimeout) { if (this._debounceTimeout) {
GLib.source_remove(this._debounceTimeout); GLib.source_remove(this._debounceTimeout);
this._debounceTimeout = null; 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');
} }
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 { 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; this._indicator = null;
}
} }
}
enable() { disable() {
try { try {
this._indicator = new TaskIndicator(); if (this._indicator) {
this._indicator.destroy();
// Add to panel - position after workspace indicator this._indicator = null;
// Panel boxes: left, center, right }
// We'll add it to the left panel, after other items } catch (e) {
Main.panel.addToStatusArea('deskmeter-task-indicator', this._indicator, 1, 'left'); // Log error but don't crash GNOME Shell
} catch (e) { logError(e, "Failed to disable Deskmeter extension");
// Log error but don't crash GNOME Shell this._indicator = null;
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;
}
} }
}
} }

View File

@@ -8,15 +8,12 @@ from flask import Blueprint, render_template
from .dm import dmbp from .dm import dmbp
from .get_period_times import ( from .get_period_times import (
get_period_totals, get_period_totals,
read_and_extract, get_work_period_totals,
task_file,
task_or_none, task_or_none,
timezone, timezone,
get_work_period_totals,
) )
class DMHTMLCalendar(calendar.HTMLCalendar): class DMHTMLCalendar(calendar.HTMLCalendar):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -148,7 +145,9 @@ def workmonth(month=None, year=None):
cal.setcalmonth(usemonth) cal.setcalmonth(usemonth)
cal.setcalyear(useyear) 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") @dmbp.route("/month")
@@ -174,4 +173,6 @@ def month(month=None, year=None, task=None):
cal.setcalmonth(usemonth) cal.setcalmonth(usemonth)
cal.setcalyear(useyear) 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
)

View File

@@ -1,9 +1,9 @@
from collections import Counter, defaultdict from collections import Counter, defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
from pymongo import MongoClient from pymongo import MongoClient
from zoneinfo import ZoneInfo
timezone = ZoneInfo("America/Argentina/Buenos_Aires") timezone = ZoneInfo("America/Argentina/Buenos_Aires")
utctz = ZoneInfo("UTC") utctz = ZoneInfo("UTC")
@@ -38,8 +38,8 @@ def parse_task_line(line):
def load_task_from_files(task_id): def load_task_from_files(task_id):
"""Search task directory files for a task ID and load it into task_history.""" """Search task directory files (recursively) for a task ID and load it into task_history."""
for task_filepath in task_dir.glob("*"): for task_filepath in task_dir.glob("**/*"):
if not task_filepath.is_file(): if not task_filepath.is_file():
continue continue
@@ -65,12 +65,14 @@ def load_task_from_files(task_id):
# Found it! Insert into task_history # Found it! Insert into task_history
task_history.update_one( task_history.update_one(
{"task_id": task_id}, {"task_id": task_id},
{"$set": { {
"path": full_path, "$set": {
"task_id": task_id, "path": full_path,
"source_file": task_filepath.name "task_id": task_id,
}}, "source_file": task_filepath.name,
upsert=True }
},
upsert=True,
) )
return full_path return full_path
except: except:
@@ -134,15 +136,10 @@ def get_task_time_seconds(start, end, task_id, workspaces=None):
"$match": { "$match": {
"date": {"$gte": start, "$lte": end}, "date": {"$gte": start, "$lte": end},
"task": task_id, "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)) 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): def task_or_none(task=None):
if not task:
task = read_and_extract(task_file)
if task == "all": if task == "all":
task = None task = None
return task return task
@@ -180,24 +173,6 @@ def convert_seconds(seconds, use_days=False):
return "{:02d}:{:02d}:{:02d}".format(hours + days * 24, 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): def get_work_period_totals(start, end):
"""Get period totals grouped by task with full path.""" """Get period totals grouped by task with full path."""
# Get all tasks with time in the period # Get all tasks with time in the period
@@ -206,15 +181,10 @@ def get_work_period_totals(start, end):
"$match": { "$match": {
"date": {"$gte": start, "$lte": end}, "date": {"$gte": start, "$lte": end},
"workspace": {"$in": ["Plan", "Think", "Work"]}, "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)) results = list(switches.aggregate(pipeline))
@@ -228,17 +198,18 @@ def get_work_period_totals(start, end):
# Get task path with history fallback # Get task path with history fallback
task_path = get_task_path(task_id) task_path = get_task_path(task_id)
combined_rows.append({ combined_rows.append(
"ws": task_path, {"ws": task_path, "total": convert_seconds(total_seconds)}
"total": convert_seconds(total_seconds) )
})
# Sort by path for consistency # Sort by path for consistency
combined_rows.sort(key=lambda x: x["ws"]) combined_rows.sort(key=lambda x: x["ws"])
return combined_rows 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. Get task blocks for calendar-style visualization, aggregated by time grid.
Shows all tasks worked on during each grid period, with overlapping blocks. 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 = { match_query = {
"date": {"$gte": start, "$lte": end}, "date": {"$gte": start, "$lte": end},
"workspace": {"$in": ["Plan", "Think", "Work"]} # Only active workspaces "workspace": {"$in": ["Plan", "Think", "Work"]}, # Only active workspaces
} }
if task_query: if task_query:
match_query["task"] = 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: while remaining_duration > 0 and current_time < switch_end:
# Calculate grid period start (hour rounded down to grid_hours) # Calculate grid period start (hour rounded down to grid_hours)
grid_hour = (current_time.hour // grid_hours) * 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) grid_end = grid_start + timedelta(hours=grid_hours)
# Time in this grid period # Time in this grid period
time_in_grid = min( time_in_grid = min(
(grid_end - current_time).total_seconds(), (grid_end - current_time).total_seconds(), remaining_duration
remaining_duration
) )
key = (current_time.date(), grid_hour, task_id) 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 = [] blocks = []
for (date, grid_hour, task_id), data in grid_task_time.items(): for (date, grid_hour, task_id), data in grid_task_time.items():
if data["duration"] >= min_block_seconds: 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({ blocks.append(
"task_id": task_id, {
"task_path": data["task_path"], "task_id": task_id,
"start": grid_start, "task_path": data["task_path"],
"end": grid_start + timedelta(seconds=data["duration"]), "start": grid_start,
"hour": grid_hour, "end": grid_start + timedelta(seconds=data["duration"]),
"duration": int(data["duration"]), "hour": grid_hour,
"active_seconds": int(data["duration"]), "duration": int(data["duration"]),
"idle_seconds": 0, "active_seconds": int(data["duration"]),
"active_ratio": 1.0 "idle_seconds": 0,
}) "active_ratio": 1.0,
}
)
return sorted(blocks, key=lambda x: (x["start"], x["task_path"])) 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 # Get task path with history fallback
task_path = get_task_path(task_id) or "No Task" task_path = get_task_path(task_id) or "No Task"
result.append({ result.append(
"workspace": switch["workspace"], {
"task_id": task_id, "workspace": switch["workspace"],
"task_path": task_path, "task_id": task_id,
"date": switch["date"].replace(tzinfo=utctz).astimezone(timezone), "task_path": task_path,
"delta": switch["delta"] "date": switch["date"].replace(tzinfo=utctz).astimezone(timezone),
}) "delta": switch["delta"],
}
)
return result return result