Compare commits

...

14 Commits

Author SHA1 Message Date
99826be6aa fe improvements 2026-02-04 08:06:17 -03:00
c97ef63756 sync fix 2026-02-04 05:05:56 -03:00
buenosairesam
de2ea3b7cb deploy updates 2026-01-26 21:39:21 -03:00
buenosairesam
3122facaba deployment, frontend updates 2026-01-26 15:11:03 -03:00
buenosairesam
bf7bcbc37a more solid task updating, no need to stop the main loop to avoid race conditions 2026-01-22 01:38:47 -03:00
buenosairesam
88caa3dc96 shit insert works 2025-12-29 14:17:17 -03:00
buenosairesam
f5ddcad45c restore legacy, include shorcut scripts 2025-12-29 14:12:46 -03:00
buenosairesam
ac475b9a5a merged worktrees, task gkt window and gnome extension 2025-12-23 19:06:43 -03:00
buenosairesam
f684da5288 Merge branch 'gnomeext' 2025-12-19 23:43:30 -03:00
buenosairesam
2307e9c5b2 51.5-354 updates 2025-12-19 23:38:21 -03:00
buenosairesam
c7ddfa6af5 fend updates 2025-12-19 23:26:03 -03:00
buenosairesam
a1ef79ad05 frontend changes 2025-12-19 23:22:42 -03:00
buenosairesam
4ebc47d79b Merge branch 'fend-updates' 2025-12-19 22:58:27 -03:00
buenosairesam
23b4341842 major fm updates 2025-12-19 22:47:38 -03:00
57 changed files with 5334 additions and 803 deletions

9
.env Normal file
View File

@@ -0,0 +1,9 @@
# Deskmeter Configuration
# dmweb API port (auto-detects if not set)
# DESKMETER_PORT=10001
# Window position for right monitor (1920x1080)
# Upper-right area (near red circle position)
WINDOW_X=3360
WINDOW_Y=100

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# Deskmeter Configuration
# Override dmweb API port (default: auto-detect 10001, then 10000)
# DESKMETER_PORT=10001
# Example for different worktree
# DESKMETER_PORT=10002
# Window position (X,Y coordinates)
# Example: middle of upper-right quadrant of left monitor (1920x1080)
# WINDOW_X=1440
# WINDOW_Y=270

112
CLAUDE.md
View File

@@ -7,9 +7,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Deskmeter is a productivity tool that measures time spent across desktop workspaces. It consists of four main components: Deskmeter is a productivity tool that measures time spent across desktop workspaces. It consists of four main components:
- **dmcore**: Core tracking daemon (`dmapp/dmcore/main.py`) that monitors active workspace and task changes - **dmcore**: Core tracking daemon (`dmapp/dmcore/main.py`) that monitors active workspace and task changes
- **dmweb**: Flask web application (`dmapp/dmweb/`) for viewing productivity data
- **dmfnt**: Angular frontend (`dmapp/dmfnt/`) for enhanced UI (in development)
- **gnome-extension**: GNOME Shell extension (`gnome-extension/deskmeter-indicator@local/`) that displays current task in the top panel
## Architecture ## Architecture
@@ -32,21 +29,21 @@ Deskmeter is a productivity tool that measures time spent across desktop workspa
### Web Interface Structure ### Web Interface Structure
**Flask Routes** (`dmapp/dmweb/dm.py`): **Flask Routes** (`dmapp/dmweb/dm.py`):
- `/` - Today's productivity summary - `/` - Today's productivity summary (large display with auto-refresh)
- `/day/<month>/<day>` - Single day view - `/calendar/<scope>/<year>/<month>/<day>?grid=<1|3|6>` - Google Calendar-style view with aggregated task blocks
- `/calendar` - Google Calendar-style task timeline view (daily/weekly/monthly) - Scopes: `daily`, `weekly`, `monthly`
- `/switches` - Raw switch documents view (daily/weekly/monthly) - Grid: Hour aggregation (1h, 3h, or 6h blocks)
- `/workmonth` - Monthly calendar showing task totals via `dmapp/dmweb/dmcal.py` - Shows only active workspaces (Plan/Think/Work) with gaps for idle time
- Cascading overlapping blocks for multiple tasks per time period
- `/switches/<scope>/<year>/<month>/<day>` - Raw switches view with proportional cell heights
- Shows all workspace switches with time-based height visualization
- `/work` - Work projects breakdown for today
- `/totals` - All-time statistics - `/totals` - All-time statistics
- `/api/current_task` - JSON API endpoint returning current task info (for GNOME extension) - `/day/<month>/<day>` - Single day view
- `/period/<start>/<end>` - Custom date range
- `/api/current_task` - JSON endpoint for current task info (used by GNOME extension)
- `/api/today` - HTML fragment for AJAX updates - `/api/today` - HTML fragment for AJAX updates
**Task Info API** (`dmapp/dmweb/get_period_times.py`):
- `get_current_task_info()` - Retrieves current task ID and path from MongoDB state collection
- `get_task_path()` - Resolves task ID to path, with automatic task_history fallback
- `get_task_blocks_calendar()` - Groups consecutive switches by task, tracks active/idle time
- `get_raw_switches()` - Returns all switch documents for a period
## Development Commands ## Development Commands
### Python Backend ### Python Backend
@@ -71,24 +68,26 @@ cd dmapp/tests
python3 test_dmapp.py python3 test_dmapp.py
``` ```
### Angular Frontend ### Running the Web Interface
Use the `dmweb.sh` script for flexible deployment:
```bash ```bash
cd dmapp/dmfnt cd /home/mariano/wdir/run
# Install dependencies # Syntax: ./dmweb.sh <worktree> <port> <debug>
npm install ./dmweb.sh dm-fend-updates 10002 1
# Development server # Parameters:
npm run start # - worktree: directory name in /home/mariano/wdir/ (default: dm)
# - port: Flask server port (default: 10000)
# Build for production # - debug: 1 for debug mode with auto-reload, 0 for production (default: 0)
npm run build
# Run tests
npm run test
``` ```
### Angular Frontend (Not Yet Implemented)
The Angular frontend (`dmapp/dmfnt/`) mentioned in the architecture is planned but not currently implemented. All current functionality is in the Flask templates.
### GNOME Extension ### GNOME Extension
```bash ```bash
@@ -243,7 +242,7 @@ Both support daily, weekly, and monthly scopes with navigation.
- The system enforces task constraints within work desktops - switching to a non-work task automatically reverts to the designated work task - The system enforces task constraints within work desktops - switching to a non-work task automatically reverts to the designated work task
- Task files are monitored for changes to enable automatic context switching - Task files are monitored for changes to enable automatic context switching
- Web interface runs on port 10000 by default - Web interface runs on port 10000 by default (configurable via `dmweb.sh` script)
- Core daemon sleeps for 2 seconds between workspace checks - Core daemon sleeps for 2 seconds between workspace checks
- GNOME extension debounce delay (2.2s) must be > dmcore polling interval (2s) to ensure MongoDB is updated before API query - GNOME extension debounce delay (2.2s) must be > dmcore polling interval (2s) to ensure MongoDB is updated before API query
@@ -285,8 +284,63 @@ Both support daily, weekly, and monthly scopes with navigation.
4. **Gradient indicates work quality** - Visual distinction between active focus and idle time 4. **Gradient indicates work quality** - Visual distinction between active focus and idle time
5. **Multiple thresholds** - Different minimum block durations for different scopes reduces noise 5. **Multiple thresholds** - Different minimum block durations for different scopes reduces noise
### UI/UX Design
**Dark Mode Theme** (applied across all views):
- Background: `#1a1a1a`
- Text: `#e0e0e0`
- Accent/links: `#6b9bd1`
- Borders: `#444`
- Component backgrounds: `#2a2a2a`
**Calendar View Features**:
- Hour-based aggregation (configurable: 1h, 3h, 6h grids)
- Overlapping task blocks cascade right and down (8% horizontal, 4px vertical per task)
- Only active workspaces shown (Plan/Think/Work) - idle time appears as gaps
- Task colors generated via hash-based HSL for consistency
- Height proportional to time spent in each grid period
**Switches View Features**:
- Cell heights proportional to switch duration (30px min, 200px max)
- Color-coded by task with hash-based HSL
- Dark backgrounds with reduced opacity for idle workspaces
**Navigation**:
- Consistent nav bar across all views: Today | Calendar | Switches | Work | All Time
- All views interconnected for easy movement between different data perspectives
## GNOME Extension
A GNOME Shell extension displays the current task in the top panel:
- Location: `gnome-extension/deskmeter-indicator@local/`
- Polls `/api/current_task` endpoint
- Updates on workspace switch with 2.2s debounce
- Automatically truncates long task paths
- Installation: `./gnome-extension/install.sh` or `./gnome-extension/update.sh`
## Key Implementation Details
**Calendar Aggregation** (`dmapp/dmweb/get_period_times.py:get_task_blocks_calendar()`):
- Aggregates switches by configurable time grid (1h, 3h, or 6h)
- Only includes active workspaces: Plan, Think, Work
- Returns blocks with task_id, task_path, start time, duration, and hour
- Filters out blocks shorter than `min_block_seconds`
**Task Path Resolution**:
- Primary: `tasks` collection (current tasks)
- Fallback: `task_history` collection (cached historical tasks)
- Last resort: On-demand file search in task directory
- Caching prevents repeated file I/O
**Switch Height Calculation** (switches view):
- Finds max delta in current view
- Heights scale linearly: `base_height + (ratio * (max_height - base_height))`
- Base: 30px, Max: 200px
## Tool Development Guidelines ## Tool Development Guidelines
- This is a personal tool, not professionally developed - This is a personal tool, not professionally developed
- reuse as it is as much as possible unless refactor is required explicitly, suggest improvements - Reuse existing code as much as possible unless refactor is required explicitly
- Only modify existing code if it absolutely doesn't make sense to add a new flow - Only modify existing code if it absolutely doesn't make sense to add a new flow
- When adding features, maintain the dark mode theme consistency
- All new views should include the standard navigation bar

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn
COPY dmapp/dmweb /app/dmweb
EXPOSE 10000
CMD ["gunicorn", "-b", "0.0.0.0:10000", "-w", "2", "dmweb:create_app()"]

View File

@@ -1,26 +1,85 @@
# Deskmeter # Deskmeter GNOME Integration
Deskmeter is a productivity tool to measure how much time you spend doing stuff in your computer. Display your current deskmeter task in the GNOME panel or in a standalone window.
More precisely how much time passes while a given workspace is active.
## Quick Start
### Task Window (No logout required)
```bash
# Run the task window with auto port detection
python3 task_window.py
# Make it always-on-top and visible on all workspaces
wmctrl -r "Deskmeter Task" -b add,above,sticky
```
### GNOME Extension (Requires logout)
Extension is already installed and enabled. Just log out and back in to activate it.
```bash
# Check status
gnome-extensions list --enabled | grep deskmeter
# View logs after login
journalctl --user -u org.gnome.Shell@wayland.service -f | grep deskmeter
```
## Features
-**Auto port detection** - Finds dmweb on ports 10001 or 10000
-**Workspace change detection** - Updates when you switch workspaces
-**Error handling** - Won't crash GNOME Shell if something fails
-**Minimal UI** - Clean display without window decorations
## Requirements ## Requirements
- MongoDB - GNOME Shell 49+ (Wayland or X11)
- wmctrl - Python 3 with GTK4 (`python3-gi`)
- Flask - dmweb running on port 10000 or 10001
- wmctrl (for workspace detection and window properties)
install those first, also wmctrl doesn't work on wayland, until an equivalent is found. you have to switch to XORG. ## Documentation
## Define Workspace Labels See [docs/](docs/) directory for detailed documentation:
edit the desktops variable in `dmmain.py` to your needs, first is workspace 1, second 2 and so on - **[READY_TO_TEST.md](docs/READY_TO_TEST.md)** - Complete testing guide (START HERE)
- **[TASK_WINDOW_README.md](docs/TASK_WINDOW_README.md)** - Task window usage and configuration
- **[INSTALL_STATUS.md](docs/INSTALL_STATUS.md)** - Extension installation and troubleshooting
- **[PORT_DETECTION_README.md](docs/PORT_DETECTION_README.md)** - How auto port detection works
## How to run ## Project Structure
leave the dmmain.py running in the background, use the dmapp flask application to see the data. ```
the homepage shows current day result, dm-gnomeext/
`/calendar` shows the current month with week totals ├── task_window.py # Standalone GTK window app
`/calendar/<month_number>` shows the select month of current year with week totals ├── run_task_window.sh # Helper script for window
`/calendar/<month_number>` shows the select month of current year with week totals ├── gnome-extension/ # Extension source
│ └── deskmeter-indicator@local/
│ ├── extension.js
│ ├── metadata.json
│ └── stylesheet.css
├── docs/ # Documentation
└── README.md # This file
```
adapt `deskmeter.sh` and `dmapp.sh` to your needs. I put those in my home directory. Add starting the mongo service ## Quick Commands
```bash
# Test task window
python3 task_window.py
# Test API
curl http://localhost:10001/api/current_task
# Check extension status
gnome-extensions list --enabled | grep deskmeter
# Make window always-on-top
wmctrl -r "Deskmeter Task" -b add,above,sticky
```
## Support
For troubleshooting and detailed information, see the [docs/](docs/) directory.

19
ctrl/deploy.sh Normal file
View File

@@ -0,0 +1,19 @@
#!/bin/bash
# Deploy deskmeter to server
# Usage: ./ctrl/deploy.sh
set -e
cd "$(dirname "$0")/.."
echo "Building dmweb image..."
docker build -t registry.mcrn.ar/dmweb:latest .
echo "Pushing to registry..."
docker push registry.mcrn.ar/dmweb:latest
echo "Deploying on server..."
rsync -avz docker-compose.yml mcrn.ar:~/dm/
ssh mcrn.ar "cd ~/dm && docker compose pull && docker compose up -d --remove-orphans"
echo "Deploy complete"
ssh mcrn.ar "cd ~/dm && docker compose ps"

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:

134
dmapp/dmdb/sync.py Normal file
View File

@@ -0,0 +1,134 @@
"""
dmsync - MongoDB Change Streams sync daemon
Watches local deskmeter database and pushes changes to remote MongoDB
Requires local MongoDB to be configured as a replica set.
Uses resume tokens to continue from last position after restart.
"""
import json
import logging
import os
from pathlib import Path
from pymongo import MongoClient, ReplaceOne
from pymongo.errors import PyMongoError
logging.basicConfig(
level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s"
)
log = logging.getLogger("dmsync")
RESUME_TOKEN_FILE = Path.home() / ".dmsync-resume-token"
REMOTE_HOST = os.environ.get("DMSYNC_REMOTE_HOST", "mcrn.ar")
REMOTE_PORT = int(os.environ.get("DMSYNC_REMOTE_PORT", 27017))
COLLECTIONS = ("switch", "task", "task_history", "state")
def load_resume_token():
"""Load resume token from file if exists."""
if RESUME_TOKEN_FILE.exists():
try:
return json.loads(RESUME_TOKEN_FILE.read_text())
except (json.JSONDecodeError, IOError) as e:
log.warning(f"Failed to load resume token: {e}")
return None
def save_resume_token(token):
"""Persist resume token to file."""
try:
RESUME_TOKEN_FILE.write_text(json.dumps(token))
except IOError as e:
log.error(f"Failed to save resume token: {e}")
def bulk_sync(local_db, remote_db):
"""Bulk sync all missing documents from local to remote."""
total_synced = 0
for coll_name in COLLECTIONS:
local_coll = local_db[coll_name]
remote_coll = remote_db[coll_name]
# Get all local docs and remote IDs
local_docs = {doc["_id"]: doc for doc in local_coll.find()}
remote_ids = set(doc["_id"] for doc in remote_coll.find({}, {"_id": 1}))
# Find missing docs
missing_ids = set(local_docs.keys()) - remote_ids
if missing_ids:
# Bulk insert missing docs
ops = [
ReplaceOne({"_id": _id}, local_docs[_id], upsert=True)
for _id in missing_ids
]
result = remote_coll.bulk_write(ops)
count = result.upserted_count + result.modified_count
log.info(f"{coll_name}: bulk synced {count} documents")
total_synced += count
return total_synced
def sync():
"""Main sync loop using Change Streams."""
log.info(f"Connecting to local MongoDB...")
local = MongoClient()
log.info(f"Connecting to remote MongoDB at {REMOTE_HOST}:{REMOTE_PORT}...")
remote = MongoClient(REMOTE_HOST, REMOTE_PORT)
local_db = local.deskmeter
remote_db = remote.deskmeter
# Bulk sync first to catch up
log.info("Performing bulk sync to catch up...")
synced = bulk_sync(local_db, remote_db)
log.info(f"Bulk sync complete: {synced} documents")
# Clear resume token to start fresh with Change Streams
# (we're now caught up, don't need to replay old changes)
if RESUME_TOKEN_FILE.exists():
RESUME_TOKEN_FILE.unlink()
log.info("Cleared old resume token")
# Now watch for new changes only (no resume token)
watch_kwargs = {"full_document": "updateLookup"}
# Watch for inserts, updates, and replaces on the database
pipeline = [{"$match": {"operationType": {"$in": ["insert", "update", "replace"]}}}]
log.info(f"Watching collections: {', '.join(COLLECTIONS)}")
try:
with local_db.watch(pipeline, **watch_kwargs) as stream:
for change in stream:
collection = change["ns"]["coll"]
if collection not in COLLECTIONS:
continue
doc = change.get("fullDocument")
if not doc:
continue
# Upsert to remote
result = remote_db[collection].replace_one(
{"_id": doc["_id"]}, doc, upsert=True
)
if result.upserted_id:
log.info(f"{collection}: inserted {doc['_id']}")
save_resume_token(stream.resume_token)
except PyMongoError as e:
log.error(f"MongoDB error: {e}")
raise
if __name__ == "__main__":
log.info("Starting dmsync daemon")
sync()

View File

@@ -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.

85
dmapp/dmos/cyclework.py Normal file
View File

@@ -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)

View File

@@ -0,0 +1,84 @@
#!/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 both clipboard and primary selection (for Shift+Insert in terminals)."""
try:
# Try wl-copy first (Wayland)
subprocess.run(["wl-copy"], input=text.encode(), check=True)
subprocess.run(["wl-copy", "--primary"], input=text.encode(), check=True)
except FileNotFoundError:
# Fallback to xclip (X11)
try:
subprocess.run(
["xclip", "-selection", "clipboard"], input=text.encode(), check=True
)
subprocess.run(
["xclip", "-selection", "primary"], 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()

View File

@@ -0,0 +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 * 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 {
_init() {
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",
});
this.add_child(this._label);
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),
);
// 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);
}
_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();
this._startPeriodicRefresh();
return;
}
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();
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();
}
_scheduleUpdate() {
// 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;
},
);
}
_updateTask() {
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));
// 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() {
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() {
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;
}
}
}

View File

@@ -10,7 +10,9 @@
"44", "44",
"45", "45",
"46", "46",
"47" "47",
"48",
"49"
], ],
"url": "", "url": "",
"version": 1 "version": 1

167
dmapp/dmos/task_window.py Normal file
View File

@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""
Deskmeter Task Window - Regular Mode
Shows current task in an always-on-top window visible on all workspaces
"""
import gi
gi.require_version('Gtk', '4.0')
from gi.repository import Gtk, GLib
import urllib.request
import json
import sys
import os
import subprocess
# Try ports in order: env var, command line arg, or try common ports
DEFAULT_PORTS = [10001, 10000] # worktree first, then default
UPDATE_INTERVAL = 500 # milliseconds - fast updates to catch workspace changes
WORKSPACE_CHECK_INTERVAL = 200 # milliseconds - check for workspace changes
# Global API URL (will be set after port detection)
DESKMETER_API_URL = None
class TaskWindow(Gtk.ApplicationWindow):
def __init__(self, app, api_url):
super().__init__(application=app, title="Deskmeter Task")
self.api_url = api_url
# Set window properties
self.set_default_size(400, 60)
# Remove window decorations (no title bar, close button, etc.)
self.set_decorated(False)
# Create label for task display
self.label = Gtk.Label(label="Loading...")
self.label.set_markup('<span font_desc="14">Loading...</span>')
self.label.set_margin_top(20)
self.label.set_margin_bottom(20)
self.label.set_margin_start(20)
self.label.set_margin_end(20)
# Set label to allow selection (useful for copying)
self.label.set_selectable(True)
# Create box container
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
box.append(self.label)
self.set_child(box)
# Track current workspace for change detection
self.current_workspace = self._get_current_workspace()
self.last_task = None
# Start periodic updates
GLib.timeout_add(UPDATE_INTERVAL, self.update_task)
# Monitor workspace changes more frequently
GLib.timeout_add(WORKSPACE_CHECK_INTERVAL, self.check_workspace_change)
# Initial update
self.update_task()
def _get_current_workspace(self):
"""Get current workspace number using wmctrl"""
try:
result = subprocess.run(['wmctrl', '-d'], capture_output=True, text=True, timeout=1)
for line in result.stdout.splitlines():
if '*' in line: # Current workspace marked with *
return line.split()[0]
except:
pass
return None
def check_workspace_change(self):
"""Check if workspace changed and trigger immediate update"""
new_workspace = self._get_current_workspace()
if new_workspace != self.current_workspace and new_workspace is not None:
self.current_workspace = new_workspace
# Workspace changed, update immediately
GLib.timeout_add(2200, self.update_task) # Wait 2.2s for dmcore to update
return True
def update_task(self):
"""Fetch current task from API and update label"""
try:
with urllib.request.urlopen(self.api_url, timeout=2) as response:
data = json.loads(response.read().decode('utf-8'))
task_path = data.get('task_path', 'no task')
# Only update label if task changed (reduces flicker)
if task_path != self.last_task:
self.last_task = task_path
# Update label with markup for better visibility
self.label.set_markup(f'<span font_desc="14" weight="bold">{task_path}</span>')
except urllib.error.URLError as e:
self.label.set_markup('<span font_desc="14" foreground="red">offline - dmweb not running</span>')
self.last_task = None
except json.JSONDecodeError as e:
self.label.set_markup('<span font_desc="14" foreground="orange">error - invalid API response</span>')
self.last_task = None
except Exception as e:
self.label.set_markup(f'<span font_desc="14" foreground="red">error: {str(e)}</span>')
self.last_task = None
# Return True to keep the timer running
return True
class TaskApp(Gtk.Application):
def __init__(self, api_url):
super().__init__(application_id='local.deskmeter.task-window')
self.api_url = api_url
def do_activate(self):
window = TaskWindow(self, self.api_url)
window.present()
def detect_api_port():
"""Try to find which port the dmweb API is running on"""
# Check environment variable first
env_port = os.environ.get('DESKMETER_PORT')
if env_port:
url = f'http://localhost:{env_port}/api/current_task'
print(f"Using port from DESKMETER_PORT env var: {env_port}")
return url
# Try common ports
for port in DEFAULT_PORTS:
url = f'http://localhost:{port}/api/current_task'
try:
with urllib.request.urlopen(url, timeout=1) as response:
# If we get here, the port is responding
print(f"Found dmweb API on port {port}")
return url
except:
continue
# Fallback to default
print(f"Could not detect dmweb API, using default port {DEFAULT_PORTS[-1]}")
return f'http://localhost:{DEFAULT_PORTS[-1]}/api/current_task'
def main():
# Check for port argument
if len(sys.argv) > 1 and sys.argv[1].isdigit():
port = sys.argv[1]
api_url = f'http://localhost:{port}/api/current_task'
print(f"Using port from command line: {port}")
else:
api_url = detect_api_port()
print(f"API URL: {api_url}")
app = TaskApp(api_url)
return app.run([])
if __name__ == '__main__':
main()

View File

@@ -1,8 +1,20 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import Blueprint, render_template, jsonify from flask import Blueprint, jsonify, render_template, request
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 from .get_period_times import (
SUPPORTED_TIMEZONES,
convert_seconds,
default_timezone,
get_current_task_info,
get_period_totals,
get_raw_switches,
get_task_blocks_calendar,
get_task_time_seconds,
get_timezone,
get_work_period_totals,
task_or_none,
)
dmbp = Blueprint("deskmeter", __name__, url_prefix="/", template_folder="templates") dmbp = Blueprint("deskmeter", __name__, url_prefix="/", template_folder="templates")
@@ -20,6 +32,12 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
Google Calendar-style view showing task blocks at their actual times. Google Calendar-style view showing task blocks at their actual times.
""" """
task = None task = None
grid = int(request.args.get("grid", 1)) # Grid hours: 1, 3, or 6
if grid not in [1, 3, 6]:
grid = 1
tz_name = request.args.get("tz")
tz = get_timezone(tz_name)
if not year: if not year:
year = datetime.today().year year = datetime.today().year
@@ -28,12 +46,16 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
if not day: if not day:
day = datetime.today().day day = datetime.today().day
base_date = datetime(year, month, day).replace(hour=0, minute=0, second=0, tzinfo=timezone) base_date = datetime(year, month, day).replace(
hour=0, minute=0, second=0, tzinfo=tz
)
if scope == "daily": if scope == "daily":
start = base_date start = base_date
end = base_date.replace(hour=23, minute=59, second=59) end = base_date.replace(hour=23, minute=59, second=59)
blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=60) blocks = get_task_blocks_calendar(
start, end, task, min_block_seconds=60, grid_hours=grid, tz=tz
)
prev_date = base_date - timedelta(days=1) prev_date = base_date - timedelta(days=1)
next_date = base_date + timedelta(days=1) next_date = base_date + timedelta(days=1)
days = [base_date] days = [base_date]
@@ -41,7 +63,9 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
elif scope == "weekly": elif scope == "weekly":
start = base_date - timedelta(days=base_date.weekday()) start = base_date - timedelta(days=base_date.weekday())
end = start + timedelta(days=6, hours=23, minutes=59, seconds=59) end = start + timedelta(days=6, hours=23, minutes=59, seconds=59)
blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=300) blocks = get_task_blocks_calendar(
start, end, task, min_block_seconds=300, grid_hours=grid, tz=tz
)
prev_date = start - timedelta(days=7) prev_date = start - timedelta(days=7)
next_date = start + timedelta(days=7) next_date = start + timedelta(days=7)
days = [start + timedelta(days=i) for i in range(7)] days = [start + timedelta(days=i) for i in range(7)]
@@ -49,18 +73,20 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
elif scope == "monthly": elif scope == "monthly":
start = base_date.replace(day=1) start = base_date.replace(day=1)
if month == 12: if month == 12:
end = datetime(year + 1, 1, 1, tzinfo=timezone) - timedelta(seconds=1) end = datetime(year + 1, 1, 1, tzinfo=tz) - timedelta(seconds=1)
else: else:
end = datetime(year, month + 1, 1, tzinfo=timezone) - timedelta(seconds=1) end = datetime(year, month + 1, 1, tzinfo=tz) - timedelta(seconds=1)
blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=600) blocks = get_task_blocks_calendar(
start, end, task, min_block_seconds=600, grid_hours=grid, tz=tz
)
if month == 1: if month == 1:
prev_date = datetime(year - 1, 12, 1, tzinfo=timezone) prev_date = datetime(year - 1, 12, 1, tzinfo=tz)
else: else:
prev_date = datetime(year, month - 1, 1, tzinfo=timezone) prev_date = datetime(year, month - 1, 1, tzinfo=tz)
if month == 12: if month == 12:
next_date = datetime(year + 1, 1, 1, tzinfo=timezone) next_date = datetime(year + 1, 1, 1, tzinfo=tz)
else: else:
next_date = datetime(year, month + 1, 1, tzinfo=timezone) next_date = datetime(year, month + 1, 1, tzinfo=tz)
days = [] days = []
current = start current = start
while current <= end: while current <= end:
@@ -70,7 +96,9 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
scope = "daily" scope = "daily"
start = base_date start = base_date
end = base_date.replace(hour=23, minute=59, second=59) end = base_date.replace(hour=23, minute=59, second=59)
blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=60) blocks = get_task_blocks_calendar(
start, end, task, min_block_seconds=60, grid_hours=grid, tz=tz
)
prev_date = base_date - timedelta(days=1) prev_date = base_date - timedelta(days=1)
next_date = base_date + timedelta(days=1) next_date = base_date + timedelta(days=1)
days = [base_date] days = [base_date]
@@ -85,7 +113,10 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
prev_date=prev_date, prev_date=prev_date,
next_date=next_date, next_date=next_date,
days=days, days=days,
auto_refresh=False grid=grid,
tz_name=tz_name,
timezones=SUPPORTED_TIMEZONES,
auto_refresh=False,
) )
@@ -98,6 +129,9 @@ def switches_view(scope="daily", year=None, month=None, day=None):
""" """
task = None task = None
tz_name = request.args.get("tz")
tz = get_timezone(tz_name)
if not year: if not year:
year = datetime.today().year year = datetime.today().year
if not month: if not month:
@@ -105,7 +139,9 @@ def switches_view(scope="daily", year=None, month=None, day=None):
if not day: if not day:
day = datetime.today().day day = datetime.today().day
base_date = datetime(year, month, day).replace(hour=0, minute=0, second=0, tzinfo=timezone) base_date = datetime(year, month, day).replace(
hour=0, minute=0, second=0, tzinfo=tz
)
if scope == "daily": if scope == "daily":
start = base_date start = base_date
@@ -122,17 +158,17 @@ def switches_view(scope="daily", year=None, month=None, day=None):
elif scope == "monthly": elif scope == "monthly":
start = base_date.replace(day=1) start = base_date.replace(day=1)
if month == 12: if month == 12:
end = datetime(year + 1, 1, 1, tzinfo=timezone) - timedelta(seconds=1) end = datetime(year + 1, 1, 1, tzinfo=tz) - timedelta(seconds=1)
else: else:
end = datetime(year, month + 1, 1, tzinfo=timezone) - timedelta(seconds=1) end = datetime(year, month + 1, 1, tzinfo=tz) - timedelta(seconds=1)
if month == 1: if month == 1:
prev_date = datetime(year - 1, 12, 1, tzinfo=timezone) prev_date = datetime(year - 1, 12, 1, tzinfo=tz)
else: else:
prev_date = datetime(year, month - 1, 1, tzinfo=timezone) prev_date = datetime(year, month - 1, 1, tzinfo=tz)
if month == 12: if month == 12:
next_date = datetime(year + 1, 1, 1, tzinfo=timezone) next_date = datetime(year + 1, 1, 1, tzinfo=tz)
else: else:
next_date = datetime(year, month + 1, 1, tzinfo=timezone) next_date = datetime(year, month + 1, 1, tzinfo=tz)
else: else:
scope = "daily" scope = "daily"
start = base_date start = base_date
@@ -140,7 +176,7 @@ def switches_view(scope="daily", year=None, month=None, day=None):
prev_date = base_date - timedelta(days=1) prev_date = base_date - timedelta(days=1)
next_date = base_date + timedelta(days=1) next_date = base_date + timedelta(days=1)
raw_switches = get_raw_switches(start, end, task) raw_switches = get_raw_switches(start, end, task, tz=tz)
return render_template( return render_template(
"switches_view.html", "switches_view.html",
@@ -151,7 +187,9 @@ def switches_view(scope="daily", year=None, month=None, day=None):
base_date=base_date, base_date=base_date,
prev_date=prev_date, prev_date=prev_date,
next_date=next_date, next_date=next_date,
auto_refresh=False tz_name=tz_name,
timezones=SUPPORTED_TIMEZONES,
auto_refresh=False,
) )
@@ -163,20 +201,28 @@ def index(task=None):
""" """
task = task_or_none(task) task = task_or_none(task)
start = datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone) start = datetime.today().replace(
end = datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone) hour=0, minute=0, second=0, tzinfo=default_timezone
)
end = datetime.today().replace(
hour=23, minute=59, second=59, tzinfo=default_timezone
)
rows = get_period_totals(start, end, task) rows = get_period_totals(start, end, task)
# Get current task info # Get current task info
current_task_id, current_task_path = 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) # Get all tasks worked on today
task_rows = get_work_period_totals(start, end)
return render_template(
"main.html",
rows=rows,
current_task_path=current_task_path,
task_rows=task_rows,
auto_refresh=True,
)
@dmbp.route("/api/current_task") @dmbp.route("/api/current_task")
@@ -185,10 +231,9 @@ def api_current_task():
JSON API endpoint returning current task information JSON API endpoint returning current task information
""" """
current_task_id, current_task_path = get_current_task_info() current_task_id, current_task_path = get_current_task_info()
return jsonify({ return jsonify(
"task_id": current_task_id, {"task_id": current_task_id, "task_path": current_task_path or "no task"}
"task_path": current_task_path or "no task" )
})
@dmbp.route("/api/today") @dmbp.route("/api/today")
@@ -199,20 +244,27 @@ def api_today(task=None):
""" """
task = task_or_none(task) task = task_or_none(task)
start = datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone) start = datetime.today().replace(
end = datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone) hour=0, minute=0, second=0, tzinfo=default_timezone
)
end = datetime.today().replace(
hour=23, minute=59, second=59, tzinfo=default_timezone
)
rows = get_period_totals(start, end, task) rows = get_period_totals(start, end, task)
# Get current task info # Get current task info
current_task_id, current_task_path = 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) # Get all tasks worked on today
task_rows = get_work_period_totals(start, end)
return render_template(
"main_content.html",
rows=rows,
current_task_path=current_task_path,
task_rows=task_rows,
)
@dmbp.route("/day/<int:month>/<int:day>") @dmbp.route("/day/<int:month>/<int:day>")
@@ -225,11 +277,11 @@ def oneday(
task = task_or_none(task) task = task_or_none(task)
start = datetime(2025, month, day).replace( start = datetime(2025, month, day).replace(
hour=0, minute=0, second=0, tzinfo=timezone hour=0, minute=0, second=0, tzinfo=default_timezone
) )
end = datetime(2025, month, day).replace( end = datetime(2025, month, day).replace(
hour=23, minute=59, second=59, tzinfo=timezone hour=23, minute=59, second=59, tzinfo=default_timezone
) )
rows = get_period_totals(start, end) rows = get_period_totals(start, end)
@@ -240,11 +292,11 @@ def oneday(
@dmbp.route("/period/<start>/<end>") @dmbp.route("/period/<start>/<end>")
def period(start, end): def period(start, end):
start = datetime(*map(int, start.split("-"))).replace( start = datetime(*map(int, start.split("-"))).replace(
hour=0, minute=0, second=0, tzinfo=timezone hour=0, minute=0, second=0, tzinfo=default_timezone
) )
end = datetime(*map(int, end.split("-"))).replace( end = datetime(*map(int, end.split("-"))).replace(
hour=23, minute=59, second=59, tzinfo=timezone hour=23, minute=59, second=59, tzinfo=default_timezone
) )
rows = get_period_totals(start, end) rows = get_period_totals(start, end)
@@ -257,8 +309,12 @@ def work():
""" """
Show total time used per work project for today Show total time used per work project for today
""" """
start = datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone) start = datetime.today().replace(
end = datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone) hour=0, minute=0, second=0, tzinfo=default_timezone
)
end = datetime.today().replace(
hour=23, minute=59, second=59, tzinfo=default_timezone
)
rows = get_work_period_totals(start, end) rows = get_work_period_totals(start, end)
@@ -274,9 +330,13 @@ def totals(task=None):
task = task_or_none(task) task = task_or_none(task)
start = datetime(2020, 1, 1).replace(hour=0, minute=0, second=0, tzinfo=timezone) start = datetime(2020, 1, 1).replace(
hour=0, minute=0, second=0, tzinfo=default_timezone
)
end = datetime(2030, 1, 1).replace(hour=23, minute=59, second=59, tzinfo=timezone) end = datetime(2030, 1, 1).replace(
hour=23, minute=59, second=59, tzinfo=default_timezone
)
rows = get_period_totals(start, end) rows = get_period_totals(start, end)

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,14 +1,37 @@
import os
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 pymongo import MongoClient
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
timezone = ZoneInfo("America/Argentina/Buenos_Aires") from pymongo import MongoClient
default_timezone = ZoneInfo("America/Argentina/Buenos_Aires")
timezone = default_timezone # Keep for backwards compatibility
utctz = ZoneInfo("UTC") utctz = ZoneInfo("UTC")
client = MongoClient() SUPPORTED_TIMEZONES = [
("America/Argentina/Buenos_Aires", "Buenos Aires"),
("America/New_York", "New York"),
("America/Los_Angeles", "Los Angeles"),
("Europe/London", "London"),
("Europe/Paris", "Paris"),
("UTC", "UTC"),
]
def get_timezone(tz_name=None):
"""Get ZoneInfo for timezone name, with validation."""
if not tz_name:
return default_timezone
# Validate against supported list
valid_names = [tz[0] for tz in SUPPORTED_TIMEZONES]
if tz_name in valid_names:
return ZoneInfo(tz_name)
return default_timezone
client = MongoClient(os.environ.get("MONGODB_HOST", "localhost"))
db = client.deskmeter db = client.deskmeter
switches = db.switch switches = db.switch
tasks = db.task tasks = db.task
@@ -38,8 +61,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 +88,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": { {
"$set": {
"path": full_path, "path": full_path,
"task_id": task_id, "task_id": task_id,
"source_file": task_filepath.name "source_file": task_filepath.name,
}}, }
upsert=True },
upsert=True,
) )
return full_path return full_path
except: except:
@@ -134,15 +159,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 +174,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 +196,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 +204,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,98 +221,120 @@ 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): def get_task_blocks_calendar(
start, end, task=None, min_block_seconds=300, grid_hours=1, tz=None
):
""" """
Get task blocks for calendar-style visualization. Get task blocks for calendar-style visualization, aggregated by time grid.
Groups consecutive switches to the same task into blocks, tracking active/idle time. Shows all tasks worked on during each grid period, with overlapping blocks.
Each task block's height is proportional to time spent in that grid period.
Args:
grid_hours: Grid size in hours (1, 3, or 6)
Returns list of blocks: Returns list of blocks:
[{ [{
'task_id': str, 'task_id': str,
'task_path': str, 'task_path': str,
'start': datetime, 'start': datetime (start of grid period),
'end': datetime, 'end': datetime (end of grid period or actual end time if less),
'duration': int (total seconds), 'duration': int (seconds in this grid block),
'active_seconds': int (Plan/Think/Work time), 'hour': int (hour of grid start, 0-23),
'idle_seconds': int (Other/Away time), 'active_seconds': int,
'active_ratio': float (0.0 to 1.0) 'active_ratio': float (always 1.0)
}, ...] }, ...]
""" """
local_tz = tz if tz else default_timezone
task_query = {"$in": task.split(",")} if task else {} task_query = {"$in": task.split(",")} if task else {}
match_query = {"date": {"$gte": start, "$lte": end}} match_query = {
"date": {"$gte": start, "$lte": end},
"workspace": {"$in": ["Plan", "Think", "Work"]}, # Only active workspaces
}
if task_query: if task_query:
match_query["task"] = task_query match_query["task"] = task_query
# Get all switches in period, sorted by date # Get all active switches in period
raw_switches = list(switches.find(match_query).sort("date", 1)) raw_switches = list(switches.find(match_query).sort("date", 1))
if not raw_switches: if not raw_switches:
return [] return []
blocks = [] # Aggregate by grid period and task
current_block = None # Structure: {(date, grid_start_hour, task_id): total_seconds}
grid_task_time = defaultdict(lambda: {"duration": 0, "task_path": None})
for switch in raw_switches: for switch in raw_switches:
ws = switch["workspace"]
task_id = switch.get("task") task_id = switch.get("task")
switch_start = switch["date"].replace(tzinfo=utctz).astimezone(timezone) switch_start = switch["date"].replace(tzinfo=utctz).astimezone(local_tz)
switch_duration = switch["delta"] switch_duration = switch["delta"]
switch_end = switch_start + timedelta(seconds=switch_duration) switch_end = switch_start + timedelta(seconds=switch_duration)
is_active = ws in ["Plan", "Think", "Work"] # Calculate how much time falls in each grid period this switch spans
current_time = switch_start
remaining_duration = switch_duration
# Start new block if task changed while remaining_duration > 0 and current_time < switch_end:
if current_block is None or current_block["task_id"] != task_id: # Calculate grid period start (hour rounded down to grid_hours)
if current_block is not None: grid_hour = (current_time.hour // grid_hours) * grid_hours
blocks.append(current_block) grid_start = current_time.replace(
hour=grid_hour, minute=0, second=0, microsecond=0
)
grid_end = grid_start + timedelta(hours=grid_hours)
# Get task path with history fallback # Time in this grid period
time_in_grid = min(
(grid_end - current_time).total_seconds(), remaining_duration
)
key = (current_time.date(), grid_hour, task_id)
# Get task path (cache it)
if grid_task_time[key]["task_path"] is None:
task_path = get_task_path(task_id) or "No Task" task_path = get_task_path(task_id) or "No Task"
grid_task_time[key]["task_path"] = task_path
current_block = { grid_task_time[key]["duration"] += time_in_grid
remaining_duration -= time_in_grid
current_time = grid_end
# Convert to blocks
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=local_tz
)
blocks.append(
{
"task_id": task_id, "task_id": task_id,
"task_path": task_path, "task_path": data["task_path"],
"start": switch_start, "start": grid_start,
"end": switch_end, "end": grid_start + timedelta(seconds=data["duration"]),
"duration": switch_duration, "hour": grid_hour,
"active_seconds": switch_duration if is_active else 0, "duration": int(data["duration"]),
"idle_seconds": 0 if is_active else switch_duration "active_seconds": int(data["duration"]),
"idle_seconds": 0,
"active_ratio": 1.0,
} }
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 return sorted(blocks, key=lambda x: (x["start"], x["task_path"]))
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): def get_raw_switches(start, end, task=None, tz=None):
""" """
Get all raw switch documents in the period. Get all raw switch documents in the period.
@@ -332,6 +347,8 @@ def get_raw_switches(start, end, task=None):
'delta': int (seconds) 'delta': int (seconds)
}, ...] }, ...]
""" """
local_tz = tz if tz else default_timezone
task_query = {"$in": task.split(",")} if task else {} task_query = {"$in": task.split(",")} if task else {}
match_query = {"date": {"$gte": start, "$lte": end}} match_query = {"date": {"$gte": start, "$lte": end}}
@@ -346,13 +363,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"], "workspace": switch["workspace"],
"task_id": task_id, "task_id": task_id,
"task_path": task_path, "task_path": task_path,
"date": switch["date"].replace(tzinfo=utctz).astimezone(timezone), "date": switch["date"].replace(tzinfo=utctz).astimezone(local_tz),
"delta": switch["delta"] "delta": switch["delta"],
}) }
)
return result return result

View File

@@ -2,22 +2,49 @@
{% block head %} {% block head %}
<style type="text/css"> <style type="text/css">
body {
background-color: #1a1a1a;
color: #e0e0e0;
margin: 20px;
font-family: monospace;
}
.nav-bar {
display: flex;
gap: 20px;
margin-bottom: 30px;
padding-bottom: 15px;
border-bottom: 2px solid #444;
}
.nav-bar a {
text-decoration: none;
color: #6b9bd1;
font-size: 12pt;
padding: 5px 10px;
border-radius: 3px;
}
.nav-bar a:hover {
background-color: #2a2a2a;
}
td > * { td > * {
} }
td { td {
vertical-align : top; vertical-align: top;
height: 25px; height: 25px;
color: #e0e0e0;
} }
table { table {
width: 100%; width: 100%;
border: 1px solid black; border: 1px solid #444;
background-color: #2a2a2a;
} }
</style> </style>
@@ -27,6 +54,14 @@ table {
{% block content %} {% block content %}
{{ content | safe }} <div class="nav-bar">
<a href="/">Today</a>
<a href="/calendar/daily">Calendar</a>
<a href="/switches/daily">Switches</a>
<a href="/work">Work</a>
<a href="/totals">All Time</a>
</div>
{{ content | safe }}
{% endblock content %} {% endblock content %}

View File

@@ -1,32 +1,48 @@
{% extends 'layout.html' %} {% extends 'layout.html' %} {% block head %}
{% block head %}
<style> <style>
body { body {
margin: 20px; margin: 20px;
font-family: monospace; font-family: monospace;
background: #fff; background: #1a1a1a;
color: #e0e0e0;
}
.nav-bar {
display: flex;
gap: 20px;
}
.nav-bar a {
text-decoration: none;
color: #6b9bd1;
font-size: 12pt;
padding: 5px 10px;
border-radius: 3px;
}
.nav-bar a:hover {
background-color: #2a2a2a;
} }
.nav-tabs { .nav-tabs {
display: flex; display: flex;
gap: 20px; gap: 20px;
margin-bottom: 20px; margin-bottom: 20px;
border-bottom: 2px solid #333; border-bottom: 2px solid #444;
padding-bottom: 10px; padding-bottom: 10px;
} }
.nav-tabs a { .nav-tabs a {
text-decoration: none; text-decoration: none;
color: #666; color: #888;
font-size: 16pt; font-size: 16pt;
padding: 5px 10px; padding: 5px 10px;
} }
.nav-tabs a.active { .nav-tabs a.active {
color: #000; color: #e0e0e0;
font-weight: bold; font-weight: bold;
border-bottom: 3px solid #000; border-bottom: 3px solid #6b9bd1;
} }
.date-nav { .date-nav {
@@ -39,33 +55,38 @@
.date-nav a { .date-nav a {
text-decoration: none; text-decoration: none;
color: #2563eb; color: #6b9bd1;
font-size: 18pt; font-size: 18pt;
} }
.date-info { .date-info {
font-weight: bold; font-weight: bold;
color: #e0e0e0;
} }
.calendar-grid { .calendar-grid {
display: flex; display: flex;
border: 1px solid #ddd; border: 1px solid #333;
min-height: 600px; min-height: 600px;
background: #1a1a1a;
} }
.time-column { .time-column {
width: 60px; width: 60px;
border-right: 1px solid #ddd; border-right: 1px solid #333;
background: #f9f9f9; background: #222;
} }
.time-slot { .time-slot {
height: 60px; border-bottom: 1px solid #2a2a2a;
border-bottom: 1px solid #eee;
padding: 5px; padding: 5px;
font-size: 10pt; font-size: 10pt;
color: #666; color: #666;
text-align: right; text-align: right;
display: flex;
align-items: flex-start;
justify-content: flex-end;
box-sizing: border-box;
} }
.days-grid { .days-grid {
@@ -75,8 +96,9 @@
.day-column { .day-column {
flex: 1; flex: 1;
border-right: 1px solid #ddd; border-right: 1px solid #333;
position: relative; position: relative;
background: #1a1a1a;
} }
.day-column:last-child { .day-column:last-child {
@@ -85,13 +107,15 @@
.day-header { .day-header {
height: 40px; height: 40px;
border-bottom: 2px solid #ddd; border-bottom: 2px solid #444;
background: #f0f0f0; background: #2a2a2a;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: bold; font-weight: bold;
font-size: 11pt; font-size: 11pt;
color: #e0e0e0;
box-sizing: border-box;
} }
.day-grid { .day-grid {
@@ -104,27 +128,34 @@
left: 0; left: 0;
right: 0; right: 0;
height: 60px; height: 60px;
border-bottom: 1px solid #eee; border-bottom: 1px solid #2a2a2a;
} }
.task-block { .task-block {
position: absolute; position: absolute;
left: 2px;
right: 2px;
border-radius: 4px; border-radius: 4px;
padding: 4px; padding: 4px;
font-size: 9pt; font-size: 9pt;
color: white; color: white;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.15s ease-out;
box-shadow: 0 1px 3px rgba(0,0,0,0.2); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(0, 0, 0, 0.3);
} }
.task-block:hover { .task-block:hover {
z-index: 100; z-index: 100 !important;
transform: scale(1.05); overflow: visible;
box-shadow: 0 4px 8px rgba(0,0,0,0.3); min-height: fit-content;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7);
}
.task-block:hover .task-label,
.task-block:hover .task-time {
background: inherit;
white-space: nowrap;
display: inline-block;
} }
.task-label { .task-label {
@@ -136,45 +167,93 @@
font-size: 8pt; font-size: 8pt;
opacity: 0.9; opacity: 0.9;
} }
.block-tooltip {
display: none;
position: absolute;
background: #333;
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 10pt;
z-index: 1000;
white-space: nowrap;
pointer-events: none;
left: 100%;
top: 0;
margin-left: 10px;
}
.task-block:hover .block-tooltip {
display: block;
}
</style> </style>
{% endblock head %} {% endblock head %} {% block content %}
{% block content %} <div
class="nav-bar"
style="
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #444;
font-size: 12pt;
"
>
<a href="/">Today</a>
<a href="/calendar/daily">Calendar</a>
<a
href="/switches/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
>Switches</a
>
<a href="/totals">All Time</a>
<select
id="tz-selector"
style="
margin-left: auto;
background: #2a2a2a;
color: #e0e0e0;
border: 1px solid #444;
padding: 5px 10px;
border-radius: 3px;
font-family: monospace;
font-size: 12pt;
"
onchange="window.location.href = window.location.pathname + '?grid={{ grid }}&tz=' + this.value"
>
{% for tz_value, tz_label in timezones %}
<option value="{{ tz_value }}" {% if tz_name == tz_value or (not tz_name and loop.first) %}selected{% endif %}>
{{ tz_label }}
</option>
{% endfor %}
</select>
</div>
<div class="nav-tabs"> <div class="nav-tabs">
<a href="/calendar/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}" <a
class="{% if scope == 'daily' %}active{% endif %}">Daily</a> href="/calendar/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid={{ grid }}{% if tz_name %}&tz={{ tz_name }}{% endif %}"
<a href="/calendar/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}" class="{% if scope == 'daily' %}active{% endif %}"
class="{% if scope == 'weekly' %}active{% endif %}">Weekly</a> >Daily</a
<a href="/calendar/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}" >
class="{% if scope == 'monthly' %}active{% endif %}">Monthly</a> <a
<span style="margin-left: auto;"> href="/calendar/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid={{ grid }}{% if tz_name %}&tz={{ tz_name }}{% endif %}"
<a href="/switches/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}">View Switches</a> class="{% if scope == 'weekly' %}active{% endif %}"
>Weekly</a
>
<a
href="/calendar/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid={{ grid }}{% if tz_name %}&tz={{ tz_name }}{% endif %}"
class="{% if scope == 'monthly' %}active{% endif %}"
>Monthly</a
>
<span
style="margin-left: auto; display: flex; gap: 15px; align-items: center"
>
<span style="color: #999; font-size: 11pt">Grid:</span>
<a
href="/calendar/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid=1{% if tz_name %}&tz={{ tz_name }}{% endif %}"
class="{% if grid == 1 %}active{% endif %}"
style="font-size: 11pt"
>1h</a
>
<a
href="/calendar/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid=3{% if tz_name %}&tz={{ tz_name }}{% endif %}"
class="{% if grid == 3 %}active{% endif %}"
style="font-size: 11pt"
>3h</a
>
<a
href="/calendar/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid=6{% if tz_name %}&tz={{ tz_name }}{% endif %}"
class="{% if grid == 6 %}active{% endif %}"
style="font-size: 11pt"
>6h</a
>
</span> </span>
</div> </div>
<div class="date-nav"> <div class="date-nav">
<a href="/calendar/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}"></a> <a
href="/calendar/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}?grid={{ grid }}{% if tz_name %}&tz={{ tz_name }}{% endif %}"
></a
>
{% if scope == 'daily' %} {% if scope == 'daily' %}
<span class="date-info">{{ base_date.strftime('%Y-%m-%d %A') }}</span> <span class="date-info">{{ base_date.strftime('%Y-%m-%d %A') }}</span>
{% elif scope == 'weekly' %} {% elif scope == 'weekly' %}
@@ -182,14 +261,19 @@
{% elif scope == 'monthly' %} {% elif scope == 'monthly' %}
<span class="date-info">{{ base_date.strftime('%B %Y') }}</span> <span class="date-info">{{ base_date.strftime('%B %Y') }}</span>
{% endif %} {% endif %}
<a href="/calendar/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}"></a> <a
href="/calendar/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}?grid={{ grid }}{% if tz_name %}&tz={{ tz_name }}{% endif %}"
></a
>
</div> </div>
<div class="calendar-grid"> <div class="calendar-grid">
<div class="time-column"> <div class="time-column">
<div class="day-header" style="height: 40px;"></div> <div class="day-header" style="height: 40px"></div>
{% for hour in range(24) %} {% for hour in range(0, 24, grid) %}
<div class="time-slot">{{ '%02d:00'|format(hour) }}</div> <div class="time-slot" style="height: {{ grid * 60 }}px;">
{{ '%02d:00'|format(hour) }}
</div>
{% endfor %} {% endfor %}
</div> </div>
@@ -197,44 +281,44 @@
{% for day in days %} {% for day in days %}
<div class="day-column"> <div class="day-column">
<div class="day-header"> <div class="day-header">
{{ day.strftime('%a %d') if scope != 'daily' else day.strftime('%A') }} {{ day.strftime('%a %d') if scope != 'daily' else
day.strftime('%A') }}
</div> </div>
<div class="day-grid"> <div class="day-grid">
{% for hour in range(24) %} {% for hour in range(0, 24, grid) %}
<div class="hour-line" style="top: {{ hour * 60 }}px;"></div> <div
{% endfor %} class="hour-line"
style="top: {{ hour * 60 }}px; height: {{ grid * 60 }}px;"
></div>
{% endfor %} {% set day_blocks = [] %} {% for block in blocks %}
{% if block.start.date() == day.date() %} {% set _ =
day_blocks.append(block) %} {% endif %} {% endfor %} {% set
hour_groups = {} %} {% for block in day_blocks %} {% set
hour_key = block.hour %} {% if hour_key not in hour_groups %} {%
set _ = hour_groups.update({hour_key: []}) %} {% endif %} {% set
_ = hour_groups[hour_key].append(block) %} {% endfor %} {% for
hour_key, hour_blocks in hour_groups.items() %} {% for block in
hour_blocks %} {% set idx = loop.index0 %} {% set base_top_px =
block.hour * 60 %} {% set duration_hours = block.duration /
3600.0 %} {% 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 %} {% set active_color =
'hsl(%d, 60%%, 45%%)'|format(base_color_hue) %} {% set
offset_pct = idx * 8 %} {% set width_pct = 100 - (idx * 8) - 2
%} {% set vertical_offset_px = idx * 4 %} {% set top_px =
base_top_px + vertical_offset_px %}
{% for block in blocks %} <div
{% if block.start.date() == day.date() %} class="task-block"
{% set start_hour = block.start.hour + block.start.minute / 60.0 %} style="top: {{ top_px }}px; height: {{ height_px }}px; left: {{ offset_pct }}%; width: {{ width_pct }}%; background: {{ active_color }}; opacity: 0.9; z-index: {{ idx + 1 }};"
{% 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) %}
<div class="task-block"
style="top: {{ top_px }}px; height: {{ height_px }}px;
background: linear-gradient(to right, {{ active_color }} 0%, {{ active_color }} {{ active_pct }}%, {{ idle_color }} {{ active_pct }}%, {{ idle_color }} 100%);">
<div class="task-label">{{ block.task_path }}</div> <div class="task-label">{{ block.task_path }}</div>
<div class="task-time">{{ block.start.strftime('%H:%M') }} ({{ (block.duration // 60)|int }}m)</div> <div class="task-time">
<div class="block-tooltip"> {{ (block.duration // 60)|int }}m
<strong>{{ block.task_path }}</strong><br>
{{ block.start.strftime('%H:%M') }} - {{ block.end.strftime('%H:%M') }}<br>
Duration: {{ (block.duration // 60)|int }} minutes<br>
Active: {{ (block.active_seconds // 60)|int }}m ({{ active_pct }}%)<br>
Idle: {{ (block.idle_seconds // 60)|int }}m
</div> </div>
</div> </div>
{% endif %} {% endfor %} {% endfor %}
{% endfor %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View File

@@ -9,6 +9,13 @@
<meta http-equiv="refresh" content="5"> <meta http-equiv="refresh" content="5">
{% endif %} {% endif %}
<style>
body {
background-color: #1a1a1a;
color: #e0e0e0;
}
</style>
{% block head %} {% block head %}
{% endblock %} {% endblock %}

View File

@@ -1,33 +1,81 @@
<html> <html>
<head> <head>
<!-- <link rel="stylesheet" href="{{ url_for('static', filename='styles/dm.css') }}"> --> <!-- <link rel="stylesheet" href="{{ url_for('static', filename='styles/dm.css') }}"> -->
{% block head %} {% block head %} {% endblock %}
{% endblock %}
<style> <style>
body body {
{
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100vh; /* This ensures that the container takes the full height of the viewport */ min-height: 100vh;
margin: 0; /* Remove default margin */ margin: 0;
padding: 20px;
box-sizing: border-box;
background-color: #1a1a1a;
color: #e0e0e0;
font-family: monospace;
}
.nav-bar {
position: absolute;
top: 20px;
left: 20px;
display: flex;
gap: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #444;
}
.nav-bar a {
text-decoration: none;
color: #6b9bd1;
font-size: 12pt;
padding: 5px 10px;
border-radius: 3px;
}
.nav-bar a:hover {
background-color: #2a2a2a;
} }
.grey { .grey {
color:grey; color: #666;
} }
.blue { .blue {
color:blue; color: #6b9bd1;
} }
table { table {
font-size: 84pt font-size: clamp(14pt, 3vw, 28pt);
border-collapse: collapse;
} }
td { td {
padding-right: 100px; padding: 0.1em 0.5em;
}
td:first-child {
text-align: right;
padding-right: 0.3em;
}
td:last-child {
text-align: left;
font-variant-numeric: tabular-nums;
}
.workspace-table {
font-size: clamp(10pt, 2vw, 18pt);
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #444;
}
.workspace-table td {
padding: 0.05em 0.4em;
} }
</style> </style>
@@ -35,10 +83,11 @@
<script> <script>
function refreshData() { function refreshData() {
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/today', true); xhr.open("GET", "/api/today", true);
xhr.onreadystatechange = function() { xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) { if (xhr.readyState === 4 && xhr.status === 200) {
document.getElementById('content-container').innerHTML = xhr.responseText; document.getElementById("content-container").innerHTML =
xhr.responseText;
} }
}; };
xhr.send(); xhr.send();
@@ -48,17 +97,20 @@
setInterval(refreshData, 5000); setInterval(refreshData, 5000);
</script> </script>
{% endif %} {% endif %}
</head> </head>
<body> <body>
<!-- agregar función que me diga cuanto tiempo hace que esta activo el escritorio <!-- agregar función que me diga cuanto tiempo hace que esta activo el escritorio
(calcular el delta con el ultimo switch y pasarlo a mm:ss) --> (calcular el delta con el ultimo switch y pasarlo a mm:ss) -->
<div class="nav-bar">
<a href="/">Today</a>
<a href="/calendar/daily">Calendar</a>
<a href="/switches/daily">Switches</a>
<a href="/totals">All Time</a>
</div>
<div id="content-container"> <div id="content-container">
{% block content %} {% block content %} {% include 'main_content.html' %} {% endblock %}
{% include 'main_content.html' %}
{% endblock %}
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,23 +1,39 @@
{% if current_task_path and current_task_time %} {% if current_task_path %}
<div id="current-task-info" style="font-size: 48pt; margin-bottom: 40px; text-align: center;"> <div
<div style="color: #333;">{{ current_task_path }}</div> id="current-task-info"
<div style="color: #666; font-size: 36pt;">{{ current_task_time }}</div> style="
font-size: clamp(10pt, 2vw, 16pt);
margin-bottom: 15px;
text-align: center;
color: #666;
"
>
Current: <span style="color: #6b9bd1">{{ current_task_path }}</span>
</div>
{% endif %} {% if task_rows %}
<div id="task-list" style="margin-bottom: 25px">
<table>
<tbody>
{% for row in task_rows %}
<tr>
<td>{{ row["ws"] }}</td>
<td>{{ row["total"] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% endif %} {% endif %}
<table>
<tbody> <table class="workspace-table">
{% for row in rows %} <tbody>
{% if row["ws"] in ['Away', 'Other'] %} {% for row in rows %} {% if row["ws"] in ['Away', 'Other'] %} {% set
{% set my_class = 'grey' %} my_class = 'grey' %} {% elif row["ws"] in ['Active', 'Idle'] %} {% set
{% elif row["ws"] in ['Active', 'Idle'] %} my_class = 'blue' %} {% else %} {% set my_class = '' %} {% endif %}
{% set my_class = 'blue' %}
{% else %}
{% set my_class = '' %}
{% endif %}
<tr> <tr>
<td class="{{my_class}}" >{{ row["ws"] }}</td> <td class="{{my_class}}">{{ row["ws"] }}</td>
<td class="{{my_class}}" >{{ row["total"] }}</td> <td class="{{my_class}}">{{ row["total"] }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -1,16 +1,81 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% block head %}
<style>
body {
background-color: #1a1a1a;
color: #e0e0e0;
margin: 20px;
font-family: monospace;
}
.nav-bar {
display: flex;
gap: 20px;
margin-bottom: 30px;
padding-bottom: 15px;
border-bottom: 2px solid #444;
flex-wrap: wrap;
}
.nav-bar a {
text-decoration: none;
color: #6b9bd1;
font-size: 12pt;
padding: 5px 10px;
border-radius: 3px;
}
.nav-bar a:hover {
background-color: #2a2a2a;
}
table {
font-size: 24pt;
border-collapse: collapse;
width: auto;
margin: 0 auto;
}
td {
padding: 10px 40px 10px 0;
color: #e0e0e0;
}
.grey {
color: #888;
}
.blue {
color: #6b9bd1;
}
</style>
{% endblock head %}
{% block content %} {% block content %}
<div class="nav-bar">
<a href="/">Today</a>
<a href="/calendar/daily">Calendar</a>
<a href="/switches/daily">Switches</a>
<a href="/work">Work</a>
<a href="/totals">All Time</a>
</div>
<table> <table>
{% for row in rows %} {% 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 %}
<tr> <tr>
<td>{{ row["ws"] }}</td> <td class="{{ my_class }}">{{ row["ws"] }}</td>
<td>{{ row["total"] }}</td> <td class="{{ my_class }}">{{ row["total"] }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
{% endblock content %} {% endblock content %}

View File

@@ -1,31 +1,48 @@
{% extends 'layout.html' %} {% extends 'layout.html' %} {% block head %}
{% block head %}
<style> <style>
body { body {
margin: 20px; margin: 20px;
font-family: monospace; font-family: monospace;
background-color: #1a1a1a;
color: #e0e0e0;
}
.nav-bar {
display: flex;
gap: 20px;
}
.nav-bar a {
text-decoration: none;
color: #6b9bd1;
font-size: 12pt;
padding: 5px 10px;
border-radius: 3px;
}
.nav-bar a:hover {
background-color: #2a2a2a;
} }
.nav-tabs { .nav-tabs {
display: flex; display: flex;
gap: 20px; gap: 20px;
margin-bottom: 20px; margin-bottom: 20px;
border-bottom: 2px solid #333; border-bottom: 2px solid #444;
padding-bottom: 10px; padding-bottom: 10px;
} }
.nav-tabs a { .nav-tabs a {
text-decoration: none; text-decoration: none;
color: #666; color: #888;
font-size: 16pt; font-size: 16pt;
padding: 5px 10px; padding: 5px 10px;
} }
.nav-tabs a.active { .nav-tabs a.active {
color: #000; color: #e0e0e0;
font-weight: bold; font-weight: bold;
border-bottom: 3px solid #000; border-bottom: 3px solid #6b9bd1;
} }
.date-nav { .date-nav {
@@ -38,18 +55,19 @@
.date-nav a { .date-nav a {
text-decoration: none; text-decoration: none;
color: #2563eb; color: #6b9bd1;
font-size: 18pt; font-size: 18pt;
} }
.date-info { .date-info {
font-weight: bold; font-weight: bold;
color: #e0e0e0;
} }
.switches-container { .switches-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 2px;
} }
.switch-row { .switch-row {
@@ -62,35 +80,41 @@
.switch-time { .switch-time {
width: 120px; width: 120px;
color: #666; color: #aaa;
font-weight: bold; font-weight: bold;
} }
.switch-workspace { .switch-workspace {
width: 80px; width: 80px;
font-weight: bold; font-weight: bold;
color: #e0e0e0;
} }
.switch-task { .switch-task {
flex: 1; flex: 1;
color: #333; color: #ccc;
} }
.switch-duration { .switch-duration {
width: 100px; width: 100px;
text-align: right; text-align: right;
color: #666; color: #aaa;
} }
/* Active vs idle workspace indicator */ /* Active vs idle workspace indicator */
.ws-active { font-weight: bold; } .ws-active {
.ws-idle { opacity: 0.6; } font-weight: bold;
}
.ws-idle {
opacity: 0.5;
}
.stats { .stats {
margin: 20px 0; margin: 20px 0;
padding: 15px; padding: 15px;
background: #f0f0f0; background: #2a2a2a;
border-radius: 4px; border-radius: 4px;
border: 1px solid #333;
} }
.stats-row { .stats-row {
@@ -105,31 +129,58 @@
} }
.stat-label { .stat-label {
color: #666; color: #999;
} }
.stat-value { .stat-value {
font-weight: bold; font-weight: bold;
color: #e0e0e0;
} }
</style> </style>
{% endblock head %} {% endblock head %} {% block content %}
{% block content %} <div
class="nav-bar"
style="
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #444;
"
>
<a href="/">Today</a>
<a href="/calendar/daily{% if tz_name %}?tz={{ tz_name }}{% endif %}"
>Calendar</a
>
<a href="/switches/daily">Switches</a>
<a href="/totals">All Time</a>
<select id="tz-selector" style="margin-left: auto; background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; padding: 5px 10px; border-radius: 3px; font-family: monospace; font-size: 12pt;" onchange="window.location.href = window.location.pathname + '?tz=' + this.value">
{% for tz_value, tz_label in timezones %}<option value="{{ tz_value }}" {% if tz_name == tz_value or (not tz_name and loop.first) %}selected{% endif %}>{{ tz_label }}</option>{% endfor %}
</select>
</div>
<div class="nav-tabs"> <div class="nav-tabs">
<a href="/switches/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}" <a
class="{% if scope == 'daily' %}active{% endif %}">Daily</a> href="/switches/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
<a href="/switches/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}" class="{% if scope == 'daily' %}active{% endif %}"
class="{% if scope == 'weekly' %}active{% endif %}">Weekly</a> >Daily</a
<a href="/switches/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}" >
class="{% if scope == 'monthly' %}active{% endif %}">Monthly</a> <a
<span style="margin-left: auto;"> href="/switches/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
<a href="/calendar/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}">View Calendar</a> class="{% if scope == 'weekly' %}active{% endif %}"
</span> >Weekly</a
>
<a
href="/switches/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
class="{% if scope == 'monthly' %}active{% endif %}"
>Monthly</a
>
</div> </div>
<div class="date-nav"> <div class="date-nav">
<a href="/switches/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}"></a> <a
href="/switches/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
></a
>
{% if scope == 'daily' %} {% if scope == 'daily' %}
<span class="date-info">{{ base_date.strftime('%Y-%m-%d %A') }}</span> <span class="date-info">{{ base_date.strftime('%Y-%m-%d %A') }}</span>
{% elif scope == 'weekly' %} {% elif scope == 'weekly' %}
@@ -137,7 +188,10 @@
{% elif scope == 'monthly' %} {% elif scope == 'monthly' %}
<span class="date-info">{{ base_date.strftime('%B %Y') }}</span> <span class="date-info">{{ base_date.strftime('%B %Y') }}</span>
{% endif %} {% endif %}
<a href="/switches/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}"></a> <a
href="/switches/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
></a
>
</div> </div>
<div class="stats"> <div class="stats">
@@ -148,7 +202,10 @@
</div> </div>
<div class="stat-item"> <div class="stat-item">
<span class="stat-label">Total Time:</span> <span class="stat-label">Total Time:</span>
<span class="stat-value">{{ (switches|sum(attribute='delta') // 3600)|int }}h {{ ((switches|sum(attribute='delta') % 3600) // 60)|int }}m</span> <span class="stat-value"
>{{ (switches|sum(attribute='delta') // 3600)|int }}h {{
((switches|sum(attribute='delta') % 3600) // 60)|int }}m</span
>
</div> </div>
</div> </div>
</div> </div>
@@ -156,30 +213,43 @@
<h3>All Switches ({{ switches|length }})</h3> <h3>All Switches ({{ switches|length }})</h3>
<div class="switches-container"> <div class="switches-container">
{% for switch in switches %} {% set max_delta = switches|map(attribute='delta')|max if switches else 1 %}
{% set is_active = switch.workspace in ['Plan', 'Think', 'Work'] %} {% set base_height = 30 %} {% set max_height = 200 %} {% for switch in
{% set task_hash = switch.task_path|hash if switch.task_path else 0 %} switches %} {% set is_active = switch.workspace in ['Plan', 'Think', 'Work']
{% set border_hue = task_hash % 360 %} %} {% set task_hash = switch.task_path|hash if switch.task_path else 0 %} {%
{% set border_color = 'hsl(%d, 70%%, 50%%)'|format(border_hue) %} set border_hue = task_hash % 360 %} {% set border_color = 'hsl(%d, 70%%,
{% set bg_color = 'hsl(%d, 70%%, 95%%)'|format(border_hue) %} 50%%)'|format(border_hue) %} {% set bg_color = 'hsl(%d, 30%%,
<div class="switch-row {{ 'ws-active' if is_active else 'ws-idle' }}" 20%%)'|format(border_hue) %} {% set height_ratio = (switch.delta /
style="border-left-color: {{ border_color }}; background-color: {{ bg_color }};"> max_delta) if max_delta > 0 else 0 %} {% set cell_height = (base_height +
<div class="switch-time">{{ switch.date.strftime('%m/%d %H:%M:%S') }}</div> (height_ratio * (max_height - base_height)))|int %}
<div
class="switch-row {{ 'ws-active' if is_active else 'ws-idle' }}"
style="border-left-color: {{ border_color }}; background-color: {{ bg_color }}; height: {{ cell_height }}px;"
>
<div class="switch-time">
{{ switch.date.strftime('%m/%d %H:%M:%S') }}
</div>
<div class="switch-workspace">{{ switch.workspace }}</div> <div class="switch-workspace">{{ switch.workspace }}</div>
<div class="switch-task">{{ switch.task_path }}</div> <div class="switch-task">{{ switch.task_path }}</div>
<div class="switch-duration"> <div class="switch-duration">
{% if switch.delta >= 3600 %} {% if switch.delta >= 3600 %} {{ (switch.delta // 3600)|int }}h {{
{{ (switch.delta // 3600)|int }}h {{ ((switch.delta % 3600) // 60)|int }}m ((switch.delta % 3600) // 60)|int }}m {% else %} {{ (switch.delta //
{% else %} 60)|int }}m {{ (switch.delta % 60)|int }}s {% endif %}
{{ (switch.delta // 60)|int }}m {{ (switch.delta % 60)|int }}s
{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% if not switches %} {% if not switches %}
<p style="text-align: center; color: #666; margin-top: 40px;">No switches in this period</p> <p style="text-align: center; color: #666; margin-top: 40px">
No switches in this period
</p>
{% endif %} {% endif %}
<style>
h3 {
color: #e0e0e0;
}
</style>
{% endblock content %} {% endblock content %}

20
dmold/dmweb/__init__.py Normal file
View File

@@ -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

BIN
dmold/dmweb/__init__.pyc Normal file

Binary file not shown.

283
dmold/dmweb/dm.py Normal file
View File

@@ -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/<string:scope>")
@dmbp.route("/calendar/<string:scope>/<int:year>/<int:month>/<int:day>")
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/<string:scope>")
@dmbp.route("/switches/<string:scope>/<int:year>/<int:month>/<int:day>")
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("/<string:task>")
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/<string:task>")
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/<int:month>/<int:day>")
@dmbp.route("/day/<string:task>/<int:month>/<int: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/<start>/<end>")
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/<string:task>")
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)

177
dmold/dmweb/dmcal.py Normal file
View File

@@ -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 = "<table class='totaltable'>"
for row in rows:
returnstr += "<tr><td>{}</td><td>{}</td></tr>".format(
row["ws"], row["total"]
)
returnstr += "</table>"
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 = "<table class='totaltable'>"
for row in rows:
returnstr += "<tr><td>{}</td><td>{}</td></tr>".format(
row["ws"], row["total"]
)
returnstr += "</table>"
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 += "<td>Week Totals</td>"
return "<tr>%s</tr>" % 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 += "<td>{}</td>".format(self.oneweek(self.dmmonth, theweek))
return "<tr>%s</tr>" % s
def formatday(self, day, weekday):
"""
Return a day as a table cell.
"""
if day == 0:
return '<td class="noday">&nbsp;</td>' # day outside month
else:
return '<td class="%s">%s</td>' % (
self.cssclasses[weekday],
self.oneday(self.dmmonth, day),
)
@dmbp.route("/workmonth")
@dmbp.route("/workmonth/<int:month>")
@dmbp.route("/workmonth/<int:month>/<int:year>")
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/<int:month>")
@dmbp.route("/month/<int:month>/<int:year>")
@dmbp.route("/month/<string:task>")
@dmbp.route("/month/<string:task>/<int:month>")
@dmbp.route("/month/<string:task>/<int:month>/<int:year>")
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)

View File

@@ -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",
# )
# )

4
dmold/dmweb/run.py Normal file
View File

@@ -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)

View File

@@ -0,0 +1,7 @@
.sat {
height: 200px;
}
table {
border: 1px solid black;
}

View File

@@ -0,0 +1,32 @@
{% extends 'layout.html' %}
{% block head %}
<style type="text/css">
td > * {
}
td {
vertical-align : top;
height: 25px;
}
table {
width: 100%;
border: 1px solid black;
}
</style>
{% endblock head %}
{% block content %}
{{ content | safe }}
{% endblock content %}

View File

@@ -0,0 +1,244 @@
{% extends 'layout.html' %}
{% block head %}
<style>
body {
margin: 20px;
font-family: monospace;
background: #fff;
}
.nav-tabs {
display: flex;
gap: 20px;
margin-bottom: 20px;
border-bottom: 2px solid #333;
padding-bottom: 10px;
}
.nav-tabs a {
text-decoration: none;
color: #666;
font-size: 16pt;
padding: 5px 10px;
}
.nav-tabs a.active {
color: #000;
font-weight: bold;
border-bottom: 3px solid #000;
}
.date-nav {
margin: 20px 0;
font-size: 14pt;
display: flex;
align-items: center;
gap: 20px;
}
.date-nav a {
text-decoration: none;
color: #2563eb;
font-size: 18pt;
}
.date-info {
font-weight: bold;
}
.calendar-grid {
display: flex;
border: 1px solid #ddd;
min-height: 600px;
}
.time-column {
width: 60px;
border-right: 1px solid #ddd;
background: #f9f9f9;
}
.time-slot {
height: 60px;
border-bottom: 1px solid #eee;
padding: 5px;
font-size: 10pt;
color: #666;
text-align: right;
}
.days-grid {
flex: 1;
display: flex;
}
.day-column {
flex: 1;
border-right: 1px solid #ddd;
position: relative;
}
.day-column:last-child {
border-right: none;
}
.day-header {
height: 40px;
border-bottom: 2px solid #ddd;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 11pt;
}
.day-grid {
position: relative;
height: 1440px; /* 24 hours * 60px */
}
.hour-line {
position: absolute;
left: 0;
right: 0;
height: 60px;
border-bottom: 1px solid #eee;
}
.task-block {
position: absolute;
left: 2px;
right: 2px;
border-radius: 4px;
padding: 4px;
font-size: 9pt;
color: white;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.task-block:hover {
z-index: 100;
transform: scale(1.05);
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}
.task-label {
font-weight: bold;
margin-bottom: 2px;
}
.task-time {
font-size: 8pt;
opacity: 0.9;
}
.block-tooltip {
display: none;
position: absolute;
background: #333;
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 10pt;
z-index: 1000;
white-space: nowrap;
pointer-events: none;
left: 100%;
top: 0;
margin-left: 10px;
}
.task-block:hover .block-tooltip {
display: block;
}
</style>
{% endblock head %}
{% block content %}
<div class="nav-tabs">
<a href="/calendar/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
class="{% if scope == 'daily' %}active{% endif %}">Daily</a>
<a href="/calendar/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
class="{% if scope == 'weekly' %}active{% endif %}">Weekly</a>
<a href="/calendar/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
class="{% if scope == 'monthly' %}active{% endif %}">Monthly</a>
<span style="margin-left: auto;">
<a href="/switches/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}">View Switches</a>
</span>
</div>
<div class="date-nav">
<a href="/calendar/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}"></a>
{% if scope == 'daily' %}
<span class="date-info">{{ base_date.strftime('%Y-%m-%d %A') }}</span>
{% elif scope == 'weekly' %}
<span class="date-info">Week of {{ start.strftime('%Y-%m-%d') }}</span>
{% elif scope == 'monthly' %}
<span class="date-info">{{ base_date.strftime('%B %Y') }}</span>
{% endif %}
<a href="/calendar/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}"></a>
</div>
<div class="calendar-grid">
<div class="time-column">
<div class="day-header" style="height: 40px;"></div>
{% for hour in range(24) %}
<div class="time-slot">{{ '%02d:00'|format(hour) }}</div>
{% endfor %}
</div>
<div class="days-grid">
{% for day in days %}
<div class="day-column">
<div class="day-header">
{{ day.strftime('%a %d') if scope != 'daily' else day.strftime('%A') }}
</div>
<div class="day-grid">
{% for hour in range(24) %}
<div class="hour-line" style="top: {{ hour * 60 }}px;"></div>
{% 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) %}
<div class="task-block"
style="top: {{ top_px }}px; height: {{ height_px }}px;
background: linear-gradient(to right, {{ active_color }} 0%, {{ active_color }} {{ active_pct }}%, {{ idle_color }} {{ active_pct }}%, {{ idle_color }} 100%);">
<div class="task-label">{{ block.task_path }}</div>
<div class="task-time">{{ block.start.strftime('%H:%M') }} ({{ (block.duration // 60)|int }}m)</div>
<div class="block-tooltip">
<strong>{{ block.task_path }}</strong><br>
{{ block.start.strftime('%H:%M') }} - {{ block.end.strftime('%H:%M') }}<br>
Duration: {{ (block.duration // 60)|int }} minutes<br>
Active: {{ (block.active_seconds // 60)|int }}m ({{ active_pct }}%)<br>
Idle: {{ (block.idle_seconds // 60)|int }}m
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock content %}

View File

@@ -0,0 +1,26 @@
<html>
<head>
<!--
<link rel="stylesheet" href="{{ url_for('static', filename='styles/dm.css') }}">
-->
{% if auto_refresh %}
<meta http-equiv="refresh" content="5">
{% endif %}
{% block head %}
{% endblock %}
</head>
<body>
{% block content %}
{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,64 @@
<html>
<head>
<!-- <link rel="stylesheet" href="{{ url_for('static', filename='styles/dm.css') }}"> -->
{% block head %}
{% endblock %}
<style>
body
{
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh; /* This ensures that the container takes the full height of the viewport */
margin: 0; /* Remove default margin */
}
.grey {
color:grey;
}
.blue {
color:blue;
}
table {
font-size: 84pt
}
td {
padding-right: 100px;
}
</style>
{% if auto_refresh %}
<script>
function refreshData() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/today', true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
document.getElementById('content-container').innerHTML = xhr.responseText;
}
};
xhr.send();
}
// Auto-refresh every 5 seconds using AJAX
setInterval(refreshData, 5000);
</script>
{% endif %}
</head>
<body>
<!-- agregar función que me diga cuanto tiempo hace que esta activo el escritorio
(calcular el delta con el ultimo switch y pasarlo a mm:ss) -->
<div id="content-container">
{% block content %}
{% include 'main_content.html' %}
{% endblock %}
</div>
</body>
</html>

View File

@@ -0,0 +1,23 @@
{% if current_task_path and current_task_time %}
<div id="current-task-info" style="font-size: 48pt; margin-bottom: 40px; text-align: center;">
<div style="color: #333;">{{ current_task_path }}</div>
<div style="color: #666; font-size: 36pt;">{{ current_task_time }}</div>
</div>
{% endif %}
<table>
<tbody>
{% 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 %}
<tr>
<td class="{{my_class}}" >{{ row["ws"] }}</td>
<td class="{{my_class}}" >{{ row["total"] }}</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,16 @@
{% extends 'layout.html' %}
{% block content %}
<table>
{% for row in rows %}
<tr>
<td>{{ row["ws"] }}</td>
<td>{{ row["total"] }}</td>
</tr>
{% endfor %}
</table>
{% endblock content %}

View File

@@ -0,0 +1,185 @@
{% extends 'layout.html' %}
{% block head %}
<style>
body {
margin: 20px;
font-family: monospace;
}
.nav-tabs {
display: flex;
gap: 20px;
margin-bottom: 20px;
border-bottom: 2px solid #333;
padding-bottom: 10px;
}
.nav-tabs a {
text-decoration: none;
color: #666;
font-size: 16pt;
padding: 5px 10px;
}
.nav-tabs a.active {
color: #000;
font-weight: bold;
border-bottom: 3px solid #000;
}
.date-nav {
margin: 20px 0;
font-size: 14pt;
display: flex;
align-items: center;
gap: 20px;
}
.date-nav a {
text-decoration: none;
color: #2563eb;
font-size: 18pt;
}
.date-info {
font-weight: bold;
}
.switches-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.switch-row {
display: flex;
align-items: center;
padding: 8px;
border-left: 4px solid;
font-size: 11pt;
}
.switch-time {
width: 120px;
color: #666;
font-weight: bold;
}
.switch-workspace {
width: 80px;
font-weight: bold;
}
.switch-task {
flex: 1;
color: #333;
}
.switch-duration {
width: 100px;
text-align: right;
color: #666;
}
/* Active vs idle workspace indicator */
.ws-active { font-weight: bold; }
.ws-idle { opacity: 0.6; }
.stats {
margin: 20px 0;
padding: 15px;
background: #f0f0f0;
border-radius: 4px;
}
.stats-row {
display: flex;
gap: 30px;
font-size: 11pt;
}
.stat-item {
display: flex;
gap: 10px;
}
.stat-label {
color: #666;
}
.stat-value {
font-weight: bold;
}
</style>
{% endblock head %}
{% block content %}
<div class="nav-tabs">
<a href="/switches/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
class="{% if scope == 'daily' %}active{% endif %}">Daily</a>
<a href="/switches/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
class="{% if scope == 'weekly' %}active{% endif %}">Weekly</a>
<a href="/switches/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
class="{% if scope == 'monthly' %}active{% endif %}">Monthly</a>
<span style="margin-left: auto;">
<a href="/calendar/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}">View Calendar</a>
</span>
</div>
<div class="date-nav">
<a href="/switches/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}"></a>
{% if scope == 'daily' %}
<span class="date-info">{{ base_date.strftime('%Y-%m-%d %A') }}</span>
{% elif scope == 'weekly' %}
<span class="date-info">Week of {{ start.strftime('%Y-%m-%d') }}</span>
{% elif scope == 'monthly' %}
<span class="date-info">{{ base_date.strftime('%B %Y') }}</span>
{% endif %}
<a href="/switches/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}"></a>
</div>
<div class="stats">
<div class="stats-row">
<div class="stat-item">
<span class="stat-label">Total Switches:</span>
<span class="stat-value">{{ switches|length }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Total Time:</span>
<span class="stat-value">{{ (switches|sum(attribute='delta') // 3600)|int }}h {{ ((switches|sum(attribute='delta') % 3600) // 60)|int }}m</span>
</div>
</div>
</div>
<h3>All Switches ({{ switches|length }})</h3>
<div class="switches-container">
{% 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) %}
<div class="switch-row {{ 'ws-active' if is_active else 'ws-idle' }}"
style="border-left-color: {{ border_color }}; background-color: {{ bg_color }};">
<div class="switch-time">{{ switch.date.strftime('%m/%d %H:%M:%S') }}</div>
<div class="switch-workspace">{{ switch.workspace }}</div>
<div class="switch-task">{{ switch.task_path }}</div>
<div class="switch-duration">
{% 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 %}
</div>
</div>
{% endfor %}
</div>
{% if not switches %}
<p style="text-align: center; color: #666; margin-top: 40px;">No switches in this period</p>
{% endif %}
{% endblock content %}

33
docker-compose.yml Normal file
View File

@@ -0,0 +1,33 @@
services:
dmweb:
build: .
image: registry.mcrn.ar/dmweb:latest
container_name: dmweb
restart: unless-stopped
depends_on:
- mongo
environment:
- MONGODB_HOST=mongo
- TZ=America/Argentina/Buenos_Aires
networks:
- gateway
- internal
mongo:
image: mongo:7
container_name: dmweb-mongo
restart: unless-stopped
volumes:
- mongo-data:/data/db
ports:
- "27017:27017"
networks:
- internal
volumes:
mongo-data:
networks:
gateway:
external: true
internal:

436
docs/README.md Normal file
View File

@@ -0,0 +1,436 @@
# Deskmeter OS Integration
Display your current deskmeter task using GNOME Shell extension or standalone GTK window.
**Location**: `dmos/` (OS-specific components)
---
## Quick Start
### GTK Task Window (Immediate - No logout)
```bash
# Run with auto port detection
python3 dmos/task_window.py
# Make it always-on-top
wmctrl -r "Deskmeter Task" -b add,above,sticky
```
### GNOME Extension (Requires logout)
```bash
cd dmos/gnome-extension
./install.sh
# Enable extension
gnome-extensions enable deskmeter-indicator@local
# Log out and back in (required on Wayland)
```
---
## Components
### 1. GTK Task Window (`dmos/task_window.py`)
Standalone GTK4 window showing current task, always-on-top and visible on all workspaces.
**Features**:
- No window decorations (minimal UI)
- Updates every 500ms
- Workspace change detection (updates 2.2s after switch)
- Auto port detection (tries 10001, then 10000)
**Usage**:
```bash
# Auto-detect port
python3 dmos/task_window.py
# Specify port
python3 dmos/task_window.py 10001
# Or via environment
DESKMETER_PORT=10001 python3 dmos/task_window.py
```
**Window States**:
- `Loading...` - Initial state
- `work/default` - Current task path
- `offline - dmweb not running` (red) - Cannot connect
- `error - invalid API response` (orange) - Invalid JSON
- `no task` - Valid response, no task set
**Close**:
- `Alt+F4`
- `pkill -f task_window.py`
### 2. GNOME Extension (`dmos/gnome-extension/`)
Panel indicator integrated into GNOME Shell top bar.
**Features**:
- Native panel integration (left side)
- Workspace switch detection with 2.2s debounce
- Auto port detection (10001, 10000)
- Error handling (won't crash GNOME Shell)
- Auto-truncates long paths (shows `.../last/two`)
**Files**:
- `extension.js` - Main extension code
- `metadata.json` - Extension metadata (GNOME 40-49)
- `stylesheet.css` - Panel styling
**Installation**:
```bash
cd dmos/gnome-extension
./install.sh # First install
# OR
./update.sh # After code changes
# Restart GNOME Shell:
# X11: Alt+F2 → 'r' → Enter
# Wayland: Log out and back in
```
**Check Status**:
```bash
# List enabled extensions
gnome-extensions list --enabled | grep deskmeter
# View logs
journalctl --user -u org.gnome.Shell@wayland.service -f | grep deskmeter
# Debug with Looking Glass
# Alt+F2 → 'lg' → Extensions tab
```
---
## Port Auto-Detection
Both components automatically detect dmweb port:
**Priority Order**:
1. Command line argument (window only)
2. Environment variable `DESKMETER_PORT` (window only)
3. Auto-detection: tries 10001, then 10000
**Test Ports**:
```bash
curl http://localhost:10001/api/current_task
curl http://localhost:10000/api/current_task
```
**Configure Ports**:
```python
# In dmos/task_window.py (line 36)
DEFAULT_PORTS = [10001, 10000]
```
```javascript
// In dmos/gnome-extension/deskmeter-indicator@local/extension.js (line 11)
const DEFAULT_PORTS = [10001, 10000];
```
---
## Requirements
### Both Components
- dmweb Flask server running (`cd dmapp/dmweb && python3 run.py`)
- `/api/current_task` endpoint accessible
### GTK Window
- Python 3 with GTK4: `sudo apt install python3-gi gir1.2-gtk-4.0`
- wmctrl: `sudo apt install wmctrl`
### GNOME Extension
- GNOME Shell 40-49
- Works on X11 and Wayland
---
## Troubleshooting
### dmweb Not Running
```bash
# Check if dmweb is running
ps aux | grep dmweb
# Check listening ports
ss -tlnp | grep -E "(10000|10001)"
# Start dmweb
cd dmapp/dmweb
python3 run.py
# Test API
curl http://localhost:10000/api/current_task
# Expected: {"task_id":"abc12345","task_path":"work/default"}
```
### GTK Window Issues
**"offline - dmweb not running"**:
- Start dmweb (see above)
**Window not staying on top**:
```bash
wmctrl -r "Deskmeter Task" -b add,above,sticky
```
**GTK4 not found**:
```bash
sudo apt install python3-gi gir1.2-gtk-4.0
```
**Window doesn't update on workspace change**:
```bash
# Test wmctrl
wmctrl -d
# Should show workspaces with * marking current
```
### GNOME Extension Issues
**Extension not showing in panel**:
```bash
# Check if enabled
gnome-extensions list --enabled | grep deskmeter
# Re-enable
gnome-extensions enable deskmeter-indicator@local
# View errors
journalctl --user -u org.gnome.Shell@wayland.service --since "5 minutes ago" | grep -i deskmeter
```
**Shows "detecting..." forever**:
- dmweb not running on 10001 or 10000
- Start dmweb and log out/in
**Shows "offline"**:
```bash
# Test API endpoint
curl http://localhost:10000/api/current_task
# Check dmweb logs for errors
```
**Changes not appearing after update**:
- Ensure `./update.sh` ran successfully
- Must restart GNOME Shell (X11) or logout/login (Wayland)
- Check logs: `journalctl -f -o cat /usr/bin/gnome-shell`
---
## Comparison: Window vs Extension
| Feature | GTK Window | GNOME Extension |
|---------|------------|-----------------|
| Visibility | Separate window | Panel indicator |
| Screen space | Takes window space | Minimal (panel) |
| Setup | Run anytime | Requires logout |
| Restart needed | No | Yes (Wayland) |
| All workspaces | ✅ (sticky) | ✅ (panel) |
| Integration | Standalone | Native GNOME |
**Use GTK window for**:
- Quick testing
- No logout required
- Temporary usage
**Use GNOME extension for**:
- Permanent setup
- Cleaner integration
- Less screen clutter
---
## Configuration
### GTK Window Settings
Edit `dmos/task_window.py`:
```python
# Update frequency (line 37)
UPDATE_INTERVAL = 2000 # milliseconds
# Workspace check frequency (line 38)
WORKSPACE_CHECK_INTERVAL = 200 # milliseconds
# Window size (line ~50)
self.set_default_size(400, 60) # width x height
# Font size (line ~27)
self.label.set_markup('<span font_desc="14">Loading...</span>')
```
### GNOME Extension Settings
Edit `dmos/gnome-extension/deskmeter-indicator@local/extension.js`:
```javascript
// Debounce delay (line 10)
const DEBOUNCE_DELAY = 2200; // milliseconds
// Port list (line 11)
const DEFAULT_PORTS = [10001, 10000];
// Update interval (line 12)
const UPDATE_INTERVAL = 30000; // 30 seconds
```
---
## Auto-start (Optional)
### GTK Window on Login
```bash
# Create desktop entry
cat > ~/.config/autostart/deskmeter-task-window.desktop <<EOF
[Desktop Entry]
Type=Application
Name=Deskmeter Task Window
Exec=/home/mariano/wdir/dm/dmos/task_window.py
Hidden=false
NoDisplay=false
X-GNOME-Autostart-enabled=true
EOF
```
### GNOME Extension
Enabled extensions auto-start with GNOME Shell. Just ensure it's enabled:
```bash
gnome-extensions enable deskmeter-indicator@local
```
---
## Files Structure
```
dmos/
├── gnome-extension/
│ ├── deskmeter-indicator@local/
│ │ ├── extension.js # Main extension code
│ │ ├── metadata.json # GNOME metadata
│ │ ├── stylesheet.css # Panel styling
│ │ └── deskmeter-indicator@local.zip # Packaged extension
│ ├── install.sh # Initial installation
│ └── update.sh # Update after changes
└── task_window.py # GTK4 window app
```
---
## Testing Checklist
### GTK Window
- [ ] Window appears without decorations
- [ ] Shows current task path
- [ ] Can be moved/positioned
- [ ] Closes with Alt+F4
- [ ] Updates on workspace change (~2s delay)
- [ ] Can set always-on-top with wmctrl
- [ ] Auto-detects port (check console)
### GNOME Extension
- [ ] Shows in panel (left side)
- [ ] Displays current task
- [ ] Updates on workspace switch (~2s delay)
- [ ] Doesn't crash GNOME Shell if dmweb offline
- [ ] Can disable: `gnome-extensions disable deskmeter-indicator@local`
- [ ] Truncates long paths correctly
---
## Performance
### GTK Window
- Workspace checks: 200ms interval (lightweight wmctrl)
- Task updates: 2000ms interval
- Post-switch delay: 2200ms (allows dmcore to update)
- CPU usage: ~0.1-0.2%
### GNOME Extension
- Event-driven (workspace switches)
- Periodic refresh: 30s
- Debounce delay: 2200ms
- Minimal overhead (GNOME Shell event loop)
---
## Development
### Updating Extension
```bash
cd dmos/gnome-extension
# 1. Edit extension.js, metadata.json, or stylesheet.css
# 2. Update installed version
./update.sh
# 3. Restart GNOME Shell
# X11: Alt+F2 → 'r' → Enter
# Wayland: Log out and back in
# 4. Check logs for errors
journalctl -f -o cat /usr/bin/gnome-shell | grep deskmeter
```
### Updating Window
```bash
# 1. Edit dmos/task_window.py
# 2. Kill running instance
pkill -f task_window.py
# 3. Run updated version
python3 dmos/task_window.py
```
---
## API Endpoint
Both components use the same endpoint:
**URL**: `http://localhost:{PORT}/api/current_task`
**Response**:
```json
{
"task_id": "abc12345",
"task_path": "work/default"
}
```
**Error Handling**:
- Connection refused → "offline"
- Invalid JSON → "error"
- Missing task_path → "no task"
---
## Need Help?
1. **Start dmweb**: `cd dmapp/dmweb && python3 run.py`
2. **Test API**: `curl http://localhost:10000/api/current_task`
3. **Try window first**: `python3 dmos/task_window.py` (no logout)
4. **Check logs**: `journalctl` for extension, console for window
5. **Verify ports**: `ss -tlnp | grep -E "(10000|10001)"`
---
**Ready to test!** Start with the GTK window for immediate feedback, then try the extension for permanent integration.

View File

@@ -0,0 +1,76 @@
digraph SystemOverview {
// Graph settings
rankdir=TB;
compound=true;
fontname="Helvetica";
node [fontname="Helvetica", fontsize=11];
edge [fontname="Helvetica", fontsize=10];
// Title
labelloc="t";
label="Deskmeter - System Architecture";
fontsize=16;
// Styling
node [shape=box, style="rounded,filled"];
// Local Machine
subgraph cluster_local {
label="Local Machine (Bare Metal)";
style=filled;
color="#E8F5E9";
fillcolor="#E8F5E9";
dmcore [label="dmcore\n(Workspace Tracker)", fillcolor="#C8E6C9"];
dmweb_local [label="dmweb\n(Dev Server)", fillcolor="#C8E6C9"];
dmsync [label="dmsync\n(Change Streams)", fillcolor="#DCEDC8"];
mongo_local [label="MongoDB\n(Replica Set)", fillcolor="#FFECB3", shape=cylinder];
}
// OS Integration
subgraph cluster_os {
label="OS Integration";
style=dashed;
color=gray;
wmctrl [label="wmctrl\n(X11 Workspaces)", fillcolor="#E3F2FD"];
gnome_ext [label="GNOME Extension\n(Panel Indicator)", fillcolor="#E3F2FD"];
}
// Remote (AWS)
subgraph cluster_remote {
label="AWS EC2 (mcrn.ar)";
style=filled;
color="#FFF3E0";
fillcolor="#FFF3E0";
subgraph cluster_docker {
label="Docker Compose";
style=dashed;
color="#F57C00";
dmweb_remote [label="dmweb\n(Flask + Gunicorn)", fillcolor="#FFE0B2"];
mongo_remote [label="MongoDB\n(Docker)", fillcolor="#FFECB3", shape=cylinder];
}
nginx [label="Nginx\n(Gateway)", fillcolor="#BBDEFB"];
}
// External
browser [label="Browser\n(Portfolio Viewer)", fillcolor="#F3E5F5"];
// Local connections
wmctrl -> dmcore [label="workspace\ndetection", color="#388E3C"];
dmcore -> mongo_local [label="write\nswitches", color="#FFA000"];
dmweb_local -> mongo_local [label="read", style=dashed, color="#666"];
gnome_ext -> dmweb_local [label="API poll", color="#1976D2", style=dashed];
// Sync connection
mongo_local -> dmsync [label="Change\nStreams", color="#7B1FA2"];
dmsync -> mongo_remote [label="push\nchanges", color="#7B1FA2", style=bold];
// Remote connections
dmweb_remote -> mongo_remote [label="read", style=dashed, color="#666"];
nginx -> dmweb_remote [label="proxy", color="#1976D2"];
browser -> nginx [label="HTTPS", color="#1976D2"];
}

View File

@@ -0,0 +1,173 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.1 (0)
-->
<!-- Title: SystemOverview Pages: 1 -->
<svg width="467pt" height="624pt"
viewBox="0.00 0.00 467.00 624.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 619.69)">
<title>SystemOverview</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-619.69 463,-619.69 463,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="229.5" y="-596.49" font-family="Helvetica,sans-Serif" font-size="16.00">Deskmeter &#45; System Architecture</text>
<g id="clust1" class="cluster">
<title>cluster_local</title>
<polygon fill="#e8f5e9" stroke="#e8f5e9" points="185,-125.62 185,-457.19 434,-457.19 434,-125.62 185,-125.62"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-437.99" font-family="Helvetica,sans-Serif" font-size="16.00">Local Machine (Bare Metal)</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_os</title>
<polygon fill="none" stroke="gray" stroke-dasharray="5,2" points="185,-500.69 185,-580.19 451,-580.19 451,-500.69 185,-500.69"/>
<text xml:space="preserve" text-anchor="middle" x="318" y="-560.99" font-family="Helvetica,sans-Serif" font-size="16.00">OS Integration</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_remote</title>
<polygon fill="#fff3e0" stroke="#fff3e0" points="8,-8 8,-334.19 177,-334.19 177,-8 8,-8"/>
<text xml:space="preserve" text-anchor="middle" x="92.5" y="-314.99" font-family="Helvetica,sans-Serif" font-size="16.00">AWS EC2 (mcrn.ar)</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_docker</title>
<polygon fill="none" stroke="#f57c00" stroke-dasharray="5,2" points="16,-16 16,-205.12 169,-205.12 169,-16 16,-16"/>
<text xml:space="preserve" text-anchor="middle" x="92.5" y="-185.93" font-family="Helvetica,sans-Serif" font-size="16.00">Docker Compose</text>
</g>
<!-- dmcore -->
<g id="node1" class="node">
<title>dmcore</title>
<path fill="#c8e6c9" stroke="black" d="M309.25,-421.69C309.25,-421.69 204.75,-421.69 204.75,-421.69 198.75,-421.69 192.75,-415.69 192.75,-409.69 192.75,-409.69 192.75,-397.69 192.75,-397.69 192.75,-391.69 198.75,-385.69 204.75,-385.69 204.75,-385.69 309.25,-385.69 309.25,-385.69 315.25,-385.69 321.25,-391.69 321.25,-397.69 321.25,-397.69 321.25,-409.69 321.25,-409.69 321.25,-415.69 315.25,-421.69 309.25,-421.69"/>
<text xml:space="preserve" text-anchor="middle" x="257" y="-406.74" font-family="Helvetica,sans-Serif" font-size="11.00">dmcore</text>
<text xml:space="preserve" text-anchor="middle" x="257" y="-393.24" font-family="Helvetica,sans-Serif" font-size="11.00">(Workspace Tracker)</text>
</g>
<!-- mongo_local -->
<g id="node4" class="node">
<title>mongo_local</title>
<path fill="#ffecb3" stroke="black" d="M300.25,-300.38C300.25,-302.79 280.86,-304.75 257,-304.75 233.14,-304.75 213.75,-302.79 213.75,-300.38 213.75,-300.38 213.75,-261 213.75,-261 213.75,-258.59 233.14,-256.62 257,-256.62 280.86,-256.62 300.25,-258.59 300.25,-261 300.25,-261 300.25,-300.38 300.25,-300.38"/>
<path fill="none" stroke="black" d="M300.25,-300.38C300.25,-297.96 280.86,-296 257,-296 233.14,-296 213.75,-297.96 213.75,-300.38"/>
<text xml:space="preserve" text-anchor="middle" x="257" y="-283.74" font-family="Helvetica,sans-Serif" font-size="11.00">MongoDB</text>
<text xml:space="preserve" text-anchor="middle" x="257" y="-270.24" font-family="Helvetica,sans-Serif" font-size="11.00">(Replica Set)</text>
</g>
<!-- dmcore&#45;&gt;mongo_local -->
<g id="edge2" class="edge">
<title>dmcore&#45;&gt;mongo_local</title>
<path fill="none" stroke="#ffa000" d="M257,-385.35C257,-367.52 257,-339.24 257,-316.72"/>
<polygon fill="#ffa000" stroke="#ffa000" points="260.5,-316.74 257,-306.74 253.5,-316.74 260.5,-316.74"/>
<text xml:space="preserve" text-anchor="middle" x="278.38" y="-358.19" font-family="Helvetica,sans-Serif" font-size="10.00">write</text>
<text xml:space="preserve" text-anchor="middle" x="278.38" y="-345.44" font-family="Helvetica,sans-Serif" font-size="10.00">switches</text>
</g>
<!-- dmweb_local -->
<g id="node2" class="node">
<title>dmweb_local</title>
<path fill="#c8e6c9" stroke="black" d="M414.25,-421.69C414.25,-421.69 351.75,-421.69 351.75,-421.69 345.75,-421.69 339.75,-415.69 339.75,-409.69 339.75,-409.69 339.75,-397.69 339.75,-397.69 339.75,-391.69 345.75,-385.69 351.75,-385.69 351.75,-385.69 414.25,-385.69 414.25,-385.69 420.25,-385.69 426.25,-391.69 426.25,-397.69 426.25,-397.69 426.25,-409.69 426.25,-409.69 426.25,-415.69 420.25,-421.69 414.25,-421.69"/>
<text xml:space="preserve" text-anchor="middle" x="383" y="-406.74" font-family="Helvetica,sans-Serif" font-size="11.00">dmweb</text>
<text xml:space="preserve" text-anchor="middle" x="383" y="-393.24" font-family="Helvetica,sans-Serif" font-size="11.00">(Dev Server)</text>
</g>
<!-- dmweb_local&#45;&gt;mongo_local -->
<g id="edge3" class="edge">
<title>dmweb_local&#45;&gt;mongo_local</title>
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M364.95,-385.35C345.23,-366.41 313.24,-335.7 289.2,-312.61"/>
<polygon fill="#666666" stroke="#666666" points="291.79,-310.24 282.15,-305.84 286.94,-315.29 291.79,-310.24"/>
<text xml:space="preserve" text-anchor="middle" x="356.64" y="-351.81" font-family="Helvetica,sans-Serif" font-size="10.00">read</text>
</g>
<!-- dmsync -->
<g id="node3" class="node">
<title>dmsync</title>
<path fill="#dcedc8" stroke="black" d="M299.88,-169.62C299.88,-169.62 208.12,-169.62 208.12,-169.62 202.12,-169.62 196.12,-163.62 196.12,-157.62 196.12,-157.62 196.12,-145.62 196.12,-145.62 196.12,-139.62 202.12,-133.62 208.12,-133.62 208.12,-133.62 299.88,-133.62 299.88,-133.62 305.88,-133.62 311.88,-139.62 311.88,-145.62 311.88,-145.62 311.88,-157.62 311.88,-157.62 311.88,-163.62 305.88,-169.62 299.88,-169.62"/>
<text xml:space="preserve" text-anchor="middle" x="254" y="-154.68" font-family="Helvetica,sans-Serif" font-size="11.00">dmsync</text>
<text xml:space="preserve" text-anchor="middle" x="254" y="-141.18" font-family="Helvetica,sans-Serif" font-size="11.00">(Change Streams)</text>
</g>
<!-- mongo_remote -->
<g id="node8" class="node">
<title>mongo_remote</title>
<path fill="#ffecb3" stroke="black" d="M154.25,-67.75C154.25,-70.16 138.9,-72.12 120,-72.12 101.1,-72.12 85.75,-70.16 85.75,-67.75 85.75,-67.75 85.75,-28.38 85.75,-28.38 85.75,-25.96 101.1,-24 120,-24 138.9,-24 154.25,-25.96 154.25,-28.38 154.25,-28.38 154.25,-67.75 154.25,-67.75"/>
<path fill="none" stroke="black" d="M154.25,-67.75C154.25,-65.34 138.9,-63.38 120,-63.38 101.1,-63.38 85.75,-65.34 85.75,-67.75"/>
<text xml:space="preserve" text-anchor="middle" x="120" y="-51.11" font-family="Helvetica,sans-Serif" font-size="11.00">MongoDB</text>
<text xml:space="preserve" text-anchor="middle" x="120" y="-37.61" font-family="Helvetica,sans-Serif" font-size="11.00">(Docker)</text>
</g>
<!-- dmsync&#45;&gt;mongo_remote -->
<g id="edge6" class="edge">
<title>dmsync&#45;&gt;mongo_remote</title>
<path fill="none" stroke="#7b1fa2" stroke-width="2" d="M230.96,-133.17C211.01,-118.04 181.75,-95.87 158.29,-78.08"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" stroke-width="2" points="161.78,-76.34 151.7,-73.09 157.55,-81.92 161.78,-76.34"/>
<text xml:space="preserve" text-anchor="middle" x="227.08" y="-106.12" font-family="Helvetica,sans-Serif" font-size="10.00">push</text>
<text xml:space="preserve" text-anchor="middle" x="227.08" y="-93.38" font-family="Helvetica,sans-Serif" font-size="10.00">changes</text>
</g>
<!-- mongo_local&#45;&gt;dmsync -->
<g id="edge5" class="edge">
<title>mongo_local&#45;&gt;dmsync</title>
<path fill="none" stroke="#7b1fa2" d="M256.45,-256.38C255.95,-235.25 255.21,-204.03 254.68,-181.35"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="258.18,-181.5 254.45,-171.58 251.18,-181.66 258.18,-181.5"/>
<text xml:space="preserve" text-anchor="middle" x="276.28" y="-229.12" font-family="Helvetica,sans-Serif" font-size="10.00">Change</text>
<text xml:space="preserve" text-anchor="middle" x="276.28" y="-216.38" font-family="Helvetica,sans-Serif" font-size="10.00">Streams</text>
</g>
<!-- wmctrl -->
<g id="node5" class="node">
<title>wmctrl</title>
<path fill="#e3f2fd" stroke="black" d="M296.88,-544.69C296.88,-544.69 205.12,-544.69 205.12,-544.69 199.12,-544.69 193.12,-538.69 193.12,-532.69 193.12,-532.69 193.12,-520.69 193.12,-520.69 193.12,-514.69 199.12,-508.69 205.12,-508.69 205.12,-508.69 296.88,-508.69 296.88,-508.69 302.88,-508.69 308.88,-514.69 308.88,-520.69 308.88,-520.69 308.88,-532.69 308.88,-532.69 308.88,-538.69 302.88,-544.69 296.88,-544.69"/>
<text xml:space="preserve" text-anchor="middle" x="251" y="-529.74" font-family="Helvetica,sans-Serif" font-size="11.00">wmctrl</text>
<text xml:space="preserve" text-anchor="middle" x="251" y="-516.24" font-family="Helvetica,sans-Serif" font-size="11.00">(X11 Workspaces)</text>
</g>
<!-- wmctrl&#45;&gt;dmcore -->
<g id="edge1" class="edge">
<title>wmctrl&#45;&gt;dmcore</title>
<path fill="none" stroke="#388e3c" d="M251.86,-508.35C252.83,-488.76 254.43,-456.58 255.58,-433.26"/>
<polygon fill="#388e3c" stroke="#388e3c" points="259.07,-433.66 256.07,-423.5 252.08,-433.32 259.07,-433.66"/>
<text xml:space="preserve" text-anchor="middle" x="280.25" y="-481.19" font-family="Helvetica,sans-Serif" font-size="10.00">workspace</text>
<text xml:space="preserve" text-anchor="middle" x="280.25" y="-468.44" font-family="Helvetica,sans-Serif" font-size="10.00">detection</text>
</g>
<!-- gnome_ext -->
<g id="node6" class="node">
<title>gnome_ext</title>
<path fill="#e3f2fd" stroke="black" d="M430.88,-544.69C430.88,-544.69 339.12,-544.69 339.12,-544.69 333.12,-544.69 327.12,-538.69 327.12,-532.69 327.12,-532.69 327.12,-520.69 327.12,-520.69 327.12,-514.69 333.12,-508.69 339.12,-508.69 339.12,-508.69 430.88,-508.69 430.88,-508.69 436.88,-508.69 442.88,-514.69 442.88,-520.69 442.88,-520.69 442.88,-532.69 442.88,-532.69 442.88,-538.69 436.88,-544.69 430.88,-544.69"/>
<text xml:space="preserve" text-anchor="middle" x="385" y="-529.74" font-family="Helvetica,sans-Serif" font-size="11.00">GNOME Extension</text>
<text xml:space="preserve" text-anchor="middle" x="385" y="-516.24" font-family="Helvetica,sans-Serif" font-size="11.00">(Panel Indicator)</text>
</g>
<!-- gnome_ext&#45;&gt;dmweb_local -->
<g id="edge4" class="edge">
<title>gnome_ext&#45;&gt;dmweb_local</title>
<path fill="none" stroke="#1976d2" stroke-dasharray="5,2" d="M384.71,-508.35C384.39,-488.76 383.86,-456.58 383.47,-433.26"/>
<polygon fill="#1976d2" stroke="#1976d2" points="386.98,-433.45 383.31,-423.51 379.98,-433.56 386.98,-433.45"/>
<text xml:space="preserve" text-anchor="middle" x="402.78" y="-474.81" font-family="Helvetica,sans-Serif" font-size="10.00">API poll</text>
</g>
<!-- dmweb_remote -->
<g id="node7" class="node">
<title>dmweb_remote</title>
<path fill="#ffe0b2" stroke="black" d="M149,-169.62C149,-169.62 55,-169.62 55,-169.62 49,-169.62 43,-163.62 43,-157.62 43,-157.62 43,-145.62 43,-145.62 43,-139.62 49,-133.62 55,-133.62 55,-133.62 149,-133.62 149,-133.62 155,-133.62 161,-139.62 161,-145.62 161,-145.62 161,-157.62 161,-157.62 161,-163.62 155,-169.62 149,-169.62"/>
<text xml:space="preserve" text-anchor="middle" x="102" y="-154.68" font-family="Helvetica,sans-Serif" font-size="11.00">dmweb</text>
<text xml:space="preserve" text-anchor="middle" x="102" y="-141.18" font-family="Helvetica,sans-Serif" font-size="11.00">(Flask + Gunicorn)</text>
</g>
<!-- dmweb_remote&#45;&gt;mongo_remote -->
<g id="edge7" class="edge">
<title>dmweb_remote&#45;&gt;mongo_remote</title>
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M105.05,-133.4C107.47,-119.79 110.91,-100.35 113.88,-83.57"/>
<polygon fill="#666666" stroke="#666666" points="117.29,-84.43 115.59,-73.97 110.39,-83.21 117.29,-84.43"/>
<text xml:space="preserve" text-anchor="middle" x="123.49" y="-99.75" font-family="Helvetica,sans-Serif" font-size="10.00">read</text>
</g>
<!-- nginx -->
<g id="node9" class="node">
<title>nginx</title>
<path fill="#bbdefb" stroke="black" d="M126.88,-298.69C126.88,-298.69 77.12,-298.69 77.12,-298.69 71.12,-298.69 65.12,-292.69 65.12,-286.69 65.12,-286.69 65.12,-274.69 65.12,-274.69 65.12,-268.69 71.12,-262.69 77.12,-262.69 77.12,-262.69 126.88,-262.69 126.88,-262.69 132.88,-262.69 138.88,-268.69 138.88,-274.69 138.88,-274.69 138.88,-286.69 138.88,-286.69 138.88,-292.69 132.88,-298.69 126.88,-298.69"/>
<text xml:space="preserve" text-anchor="middle" x="102" y="-283.74" font-family="Helvetica,sans-Serif" font-size="11.00">Nginx</text>
<text xml:space="preserve" text-anchor="middle" x="102" y="-270.24" font-family="Helvetica,sans-Serif" font-size="11.00">(Gateway)</text>
</g>
<!-- nginx&#45;&gt;dmweb_remote -->
<g id="edge8" class="edge">
<title>nginx&#45;&gt;dmweb_remote</title>
<path fill="none" stroke="#1976d2" d="M102,-262.29C102,-241.38 102,-206 102,-181.09"/>
<polygon fill="#1976d2" stroke="#1976d2" points="105.5,-181.33 102,-171.33 98.5,-181.33 105.5,-181.33"/>
<text xml:space="preserve" text-anchor="middle" x="115.88" y="-222.75" font-family="Helvetica,sans-Serif" font-size="10.00">proxy</text>
</g>
<!-- browser -->
<g id="node10" class="node">
<title>browser</title>
<path fill="#f3e5f5" stroke="black" d="M145.62,-421.69C145.62,-421.69 58.38,-421.69 58.38,-421.69 52.38,-421.69 46.38,-415.69 46.38,-409.69 46.38,-409.69 46.38,-397.69 46.38,-397.69 46.38,-391.69 52.38,-385.69 58.38,-385.69 58.38,-385.69 145.62,-385.69 145.62,-385.69 151.62,-385.69 157.62,-391.69 157.62,-397.69 157.62,-397.69 157.62,-409.69 157.62,-409.69 157.62,-415.69 151.62,-421.69 145.62,-421.69"/>
<text xml:space="preserve" text-anchor="middle" x="102" y="-406.74" font-family="Helvetica,sans-Serif" font-size="11.00">Browser</text>
<text xml:space="preserve" text-anchor="middle" x="102" y="-393.24" font-family="Helvetica,sans-Serif" font-size="11.00">(Portfolio Viewer)</text>
</g>
<!-- browser&#45;&gt;nginx -->
<g id="edge9" class="edge">
<title>browser&#45;&gt;nginx</title>
<path fill="none" stroke="#1976d2" d="M102,-385.35C102,-365.76 102,-333.58 102,-310.26"/>
<polygon fill="#1976d2" stroke="#1976d2" points="105.5,-310.51 102,-300.51 98.5,-310.51 105.5,-310.51"/>
<text xml:space="preserve" text-anchor="middle" x="117.75" y="-351.81" font-family="Helvetica,sans-Serif" font-size="10.00">HTTPS</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,76 @@
digraph DataSync {
// Graph settings
rankdir=LR;
compound=true;
fontname="Helvetica";
node [fontname="Helvetica", fontsize=11];
edge [fontname="Helvetica", fontsize=10];
// Title
labelloc="t";
label="Deskmeter - Change Streams Data Sync";
fontsize=16;
// Styling
node [shape=box, style="rounded,filled"];
// Local MongoDB
subgraph cluster_local_mongo {
label="Local MongoDB (Replica Set)";
style=filled;
color="#E8F5E9";
fillcolor="#E8F5E9";
oplog [label="Oplog", fillcolor="#C8E6C9", shape=cylinder];
subgraph cluster_collections {
label="Collections";
style=dashed;
color="#388E3C";
switch_coll [label="switch\n(workspace events)", fillcolor="#DCEDC8"];
task_coll [label="task\n(current tasks)", fillcolor="#DCEDC8"];
state_coll [label="state\n(current state)", fillcolor="#DCEDC8"];
history_coll [label="task_history\n(path cache)", fillcolor="#DCEDC8"];
}
}
// dmsync daemon
subgraph cluster_dmsync {
label="dmsync Daemon";
style=filled;
color="#F3E5F5";
fillcolor="#F3E5F5";
watcher [label="Change Stream\nWatcher", fillcolor="#E1BEE7"];
resume_token [label="Resume Token\n(~/.dmsync-resume-token)", fillcolor="#E1BEE7", shape=note];
}
// Remote MongoDB
subgraph cluster_remote_mongo {
label="Remote MongoDB (Docker)";
style=filled;
color="#FFF3E0";
fillcolor="#FFF3E0";
remote_switch [label="switch", fillcolor="#FFE0B2"];
remote_task [label="task", fillcolor="#FFE0B2"];
remote_state [label="state", fillcolor="#FFE0B2"];
remote_history [label="task_history", fillcolor="#FFE0B2"];
}
// Flow
switch_coll -> oplog [style=invis];
task_coll -> oplog [style=invis];
state_coll -> oplog [style=invis];
history_coll -> oplog [style=invis];
oplog -> watcher [label="watch()", color="#7B1FA2", style=bold];
watcher -> resume_token [label="persist", color="#666", style=dashed];
resume_token -> watcher [label="resume_after", color="#666", style=dashed];
watcher -> remote_switch [label="upsert", color="#F57C00"];
watcher -> remote_task [label="upsert", color="#F57C00"];
watcher -> remote_state [label="upsert", color="#F57C00"];
watcher -> remote_history [label="upsert", color="#F57C00"];
}

View File

@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.1 (0)
-->
<!-- Title: DataSync Pages: 1 -->
<svg width="760pt" height="499pt"
viewBox="0.00 0.00 760.00 499.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 494.5)">
<title>DataSync</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-494.5 755.75,-494.5 755.75,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="375.88" y="-471.3" font-family="Helvetica,sans-Serif" font-size="16.00">Deskmeter &#45; Change Streams Data Sync</text>
<g id="clust1" class="cluster">
<title>cluster_local_mongo</title>
<polygon fill="#e8f5e9" stroke="#e8f5e9" points="4.5,-169 4.5,-455 252.25,-455 252.25,-169 4.5,-169"/>
<text xml:space="preserve" text-anchor="middle" x="128.38" y="-435.8" font-family="Helvetica,sans-Serif" font-size="16.00">Local MongoDB (Replica Set)</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_collections</title>
<polygon fill="none" stroke="#388e3c" stroke-dasharray="5,2" points="16,-177 16,-419 156.75,-419 156.75,-177 16,-177"/>
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-399.8" font-family="Helvetica,sans-Serif" font-size="16.00">Collections</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_dmsync</title>
<polygon fill="#f3e5f5" stroke="#f3e5f5" points="309,-258 309,-368 724.38,-368 724.38,-258 309,-258"/>
<text xml:space="preserve" text-anchor="middle" x="516.69" y="-348.8" font-family="Helvetica,sans-Serif" font-size="16.00">dmsync Daemon</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_remote_mongo</title>
<polygon fill="#fff3e0" stroke="#fff3e0" points="518.25,-8 518.25,-250 751.75,-250 751.75,-8 518.25,-8"/>
<text xml:space="preserve" text-anchor="middle" x="635" y="-230.8" font-family="Helvetica,sans-Serif" font-size="16.00">Remote MongoDB (Docker)</text>
</g>
<!-- oplog -->
<g id="node1" class="node">
<title>oplog</title>
<path fill="#c8e6c9" stroke="black" d="M239.75,-298.73C239.75,-300.53 227.65,-302 212.75,-302 197.85,-302 185.75,-300.53 185.75,-298.73 185.75,-298.73 185.75,-269.27 185.75,-269.27 185.75,-267.47 197.85,-266 212.75,-266 227.65,-266 239.75,-267.47 239.75,-269.27 239.75,-269.27 239.75,-298.73 239.75,-298.73"/>
<path fill="none" stroke="black" d="M239.75,-298.73C239.75,-296.92 227.65,-295.45 212.75,-295.45 197.85,-295.45 185.75,-296.92 185.75,-298.73"/>
<text xml:space="preserve" text-anchor="middle" x="212.75" y="-280.3" font-family="Helvetica,sans-Serif" font-size="11.00">Oplog</text>
</g>
<!-- watcher -->
<g id="node6" class="node">
<title>watcher</title>
<path fill="#e1bee7" stroke="black" d="M405.75,-302C405.75,-302 329,-302 329,-302 323,-302 317,-296 317,-290 317,-290 317,-278 317,-278 317,-272 323,-266 329,-266 329,-266 405.75,-266 405.75,-266 411.75,-266 417.75,-272 417.75,-278 417.75,-278 417.75,-290 417.75,-290 417.75,-296 411.75,-302 405.75,-302"/>
<text xml:space="preserve" text-anchor="middle" x="367.38" y="-287.05" font-family="Helvetica,sans-Serif" font-size="11.00">Change Stream</text>
<text xml:space="preserve" text-anchor="middle" x="367.38" y="-273.55" font-family="Helvetica,sans-Serif" font-size="11.00">Watcher</text>
</g>
<!-- oplog&#45;&gt;watcher -->
<g id="edge5" class="edge">
<title>oplog&#45;&gt;watcher</title>
<path fill="none" stroke="#7b1fa2" stroke-width="2" d="M239.99,-284C258,-284 282.67,-284 305.43,-284"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" stroke-width="2" points="303.74,-287.5 313.74,-284 303.74,-280.5 303.74,-287.5"/>
<text xml:space="preserve" text-anchor="middle" x="280.62" y="-287.25" font-family="Helvetica,sans-Serif" font-size="10.00">watch()</text>
</g>
<!-- switch_coll -->
<g id="node2" class="node">
<title>switch_coll</title>
<path fill="#dcedc8" stroke="black" d="M136.75,-221C136.75,-221 36,-221 36,-221 30,-221 24,-215 24,-209 24,-209 24,-197 24,-197 24,-191 30,-185 36,-185 36,-185 136.75,-185 136.75,-185 142.75,-185 148.75,-191 148.75,-197 148.75,-197 148.75,-209 148.75,-209 148.75,-215 142.75,-221 136.75,-221"/>
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-206.05" font-family="Helvetica,sans-Serif" font-size="11.00">switch</text>
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-192.55" font-family="Helvetica,sans-Serif" font-size="11.00">(workspace events)</text>
</g>
<!-- switch_coll&#45;&gt;oplog -->
<!-- task_coll -->
<g id="node3" class="node">
<title>task_coll</title>
<path fill="#dcedc8" stroke="black" d="M123.25,-275C123.25,-275 49.5,-275 49.5,-275 43.5,-275 37.5,-269 37.5,-263 37.5,-263 37.5,-251 37.5,-251 37.5,-245 43.5,-239 49.5,-239 49.5,-239 123.25,-239 123.25,-239 129.25,-239 135.25,-245 135.25,-251 135.25,-251 135.25,-263 135.25,-263 135.25,-269 129.25,-275 123.25,-275"/>
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-260.05" font-family="Helvetica,sans-Serif" font-size="11.00">task</text>
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-246.55" font-family="Helvetica,sans-Serif" font-size="11.00">(current tasks)</text>
</g>
<!-- task_coll&#45;&gt;oplog -->
<!-- state_coll -->
<g id="node4" class="node">
<title>state_coll</title>
<path fill="#dcedc8" stroke="black" d="M122.88,-329C122.88,-329 49.88,-329 49.88,-329 43.88,-329 37.88,-323 37.88,-317 37.88,-317 37.88,-305 37.88,-305 37.88,-299 43.88,-293 49.88,-293 49.88,-293 122.88,-293 122.88,-293 128.88,-293 134.88,-299 134.88,-305 134.88,-305 134.88,-317 134.88,-317 134.88,-323 128.88,-329 122.88,-329"/>
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-314.05" font-family="Helvetica,sans-Serif" font-size="11.00">state</text>
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-300.55" font-family="Helvetica,sans-Serif" font-size="11.00">(current state)</text>
</g>
<!-- state_coll&#45;&gt;oplog -->
<!-- history_coll -->
<g id="node5" class="node">
<title>history_coll</title>
<path fill="#dcedc8" stroke="black" d="M117.25,-383C117.25,-383 55.5,-383 55.5,-383 49.5,-383 43.5,-377 43.5,-371 43.5,-371 43.5,-359 43.5,-359 43.5,-353 49.5,-347 55.5,-347 55.5,-347 117.25,-347 117.25,-347 123.25,-347 129.25,-353 129.25,-359 129.25,-359 129.25,-371 129.25,-371 129.25,-377 123.25,-383 117.25,-383"/>
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-368.05" font-family="Helvetica,sans-Serif" font-size="11.00">task_history</text>
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-354.55" font-family="Helvetica,sans-Serif" font-size="11.00">(path cache)</text>
</g>
<!-- history_coll&#45;&gt;oplog -->
<!-- resume_token -->
<g id="node7" class="node">
<title>resume_token</title>
<polygon fill="#e1bee7" stroke="black" points="710.38,-317 552.62,-317 552.62,-281 716.38,-281 716.38,-311 710.38,-317"/>
<polyline fill="none" stroke="black" points="710.38,-317 710.38,-311"/>
<polyline fill="none" stroke="black" points="716.38,-311 710.38,-311"/>
<text xml:space="preserve" text-anchor="middle" x="634.5" y="-302.05" font-family="Helvetica,sans-Serif" font-size="11.00">Resume Token</text>
<text xml:space="preserve" text-anchor="middle" x="634.5" y="-288.55" font-family="Helvetica,sans-Serif" font-size="11.00">(~/.dmsync&#45;resume&#45;token)</text>
</g>
<!-- watcher&#45;&gt;resume_token -->
<g id="edge6" class="edge">
<title>watcher&#45;&gt;resume_token</title>
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M418.2,-283.38C442.89,-283.35 473.16,-283.73 500.25,-285.25 513.4,-285.99 527.26,-287.07 540.86,-288.32"/>
<polygon fill="#666666" stroke="#666666" points="540.41,-291.79 550.7,-289.25 541.07,-284.82 540.41,-291.79"/>
<text xml:space="preserve" text-anchor="middle" x="468" y="-288.5" font-family="Helvetica,sans-Serif" font-size="10.00">persist</text>
</g>
<!-- remote_switch -->
<g id="node8" class="node">
<title>remote_switch</title>
<path fill="#ffe0b2" stroke="black" d="M649.5,-214C649.5,-214 619.5,-214 619.5,-214 613.5,-214 607.5,-208 607.5,-202 607.5,-202 607.5,-190 607.5,-190 607.5,-184 613.5,-178 619.5,-178 619.5,-178 649.5,-178 649.5,-178 655.5,-178 661.5,-184 661.5,-190 661.5,-190 661.5,-202 661.5,-202 661.5,-208 655.5,-214 649.5,-214"/>
<text xml:space="preserve" text-anchor="middle" x="634.5" y="-192.3" font-family="Helvetica,sans-Serif" font-size="11.00">switch</text>
</g>
<!-- watcher&#45;&gt;remote_switch -->
<g id="edge8" class="edge">
<title>watcher&#45;&gt;remote_switch</title>
<path fill="none" stroke="#f57c00" d="M418.24,-267.45C470.07,-250.25 549.97,-223.72 596.46,-208.29"/>
<polygon fill="#f57c00" stroke="#f57c00" points="597.42,-211.66 605.81,-205.19 595.21,-205.02 597.42,-211.66"/>
<text xml:space="preserve" text-anchor="middle" x="468" y="-263.65" font-family="Helvetica,sans-Serif" font-size="10.00">upsert</text>
</g>
<!-- remote_task -->
<g id="node9" class="node">
<title>remote_task</title>
<path fill="#ffe0b2" stroke="black" d="M649.5,-160C649.5,-160 619.5,-160 619.5,-160 613.5,-160 607.5,-154 607.5,-148 607.5,-148 607.5,-136 607.5,-136 607.5,-130 613.5,-124 619.5,-124 619.5,-124 649.5,-124 649.5,-124 655.5,-124 661.5,-130 661.5,-136 661.5,-136 661.5,-148 661.5,-148 661.5,-154 655.5,-160 649.5,-160"/>
<text xml:space="preserve" text-anchor="middle" x="634.5" y="-138.3" font-family="Helvetica,sans-Serif" font-size="11.00">task</text>
</g>
<!-- watcher&#45;&gt;remote_task -->
<g id="edge9" class="edge">
<title>watcher&#45;&gt;remote_task</title>
<path fill="none" stroke="#f57c00" d="M386.34,-265.52C412.71,-239.57 464.61,-192.85 518.25,-169 543.15,-157.93 573.17,-151.08 596.34,-147.06"/>
<polygon fill="#f57c00" stroke="#f57c00" points="596.63,-150.55 605.93,-145.49 595.5,-143.65 596.63,-150.55"/>
<text xml:space="preserve" text-anchor="middle" x="468" y="-224.4" font-family="Helvetica,sans-Serif" font-size="10.00">upsert</text>
</g>
<!-- remote_state -->
<g id="node10" class="node">
<title>remote_state</title>
<path fill="#ffe0b2" stroke="black" d="M649.5,-106C649.5,-106 619.5,-106 619.5,-106 613.5,-106 607.5,-100 607.5,-94 607.5,-94 607.5,-82 607.5,-82 607.5,-76 613.5,-70 619.5,-70 619.5,-70 649.5,-70 649.5,-70 655.5,-70 661.5,-76 661.5,-82 661.5,-82 661.5,-94 661.5,-94 661.5,-100 655.5,-106 649.5,-106"/>
<text xml:space="preserve" text-anchor="middle" x="634.5" y="-84.3" font-family="Helvetica,sans-Serif" font-size="11.00">state</text>
</g>
<!-- watcher&#45;&gt;remote_state -->
<g id="edge10" class="edge">
<title>watcher&#45;&gt;remote_state</title>
<path fill="none" stroke="#f57c00" d="M374.68,-265.75C384.38,-239.86 405.12,-192.16 435.75,-161.25 479.42,-117.18 551.93,-99.36 595.96,-92.35"/>
<polygon fill="#f57c00" stroke="#f57c00" points="596.42,-95.81 605.81,-90.9 595.41,-88.89 596.42,-95.81"/>
<text xml:space="preserve" text-anchor="middle" x="468" y="-164.5" font-family="Helvetica,sans-Serif" font-size="10.00">upsert</text>
</g>
<!-- remote_history -->
<g id="node11" class="node">
<title>remote_history</title>
<path fill="#ffe0b2" stroke="black" d="M663.88,-52C663.88,-52 605.12,-52 605.12,-52 599.12,-52 593.12,-46 593.12,-40 593.12,-40 593.12,-28 593.12,-28 593.12,-22 599.12,-16 605.12,-16 605.12,-16 663.88,-16 663.88,-16 669.88,-16 675.88,-22 675.88,-28 675.88,-28 675.88,-40 675.88,-40 675.88,-46 669.88,-52 663.88,-52"/>
<text xml:space="preserve" text-anchor="middle" x="634.5" y="-30.3" font-family="Helvetica,sans-Serif" font-size="11.00">task_history</text>
</g>
<!-- watcher&#45;&gt;remote_history -->
<g id="edge11" class="edge">
<title>watcher&#45;&gt;remote_history</title>
<path fill="none" stroke="#f57c00" d="M370.83,-265.73C376.42,-230.55 393.07,-152.59 435.75,-104.25 472.79,-62.29 536.59,-45.39 581.69,-38.59"/>
<polygon fill="#f57c00" stroke="#f57c00" points="582.09,-42.07 591.52,-37.24 581.13,-35.13 582.09,-42.07"/>
<text xml:space="preserve" text-anchor="middle" x="468" y="-107.5" font-family="Helvetica,sans-Serif" font-size="10.00">upsert</text>
</g>
<!-- resume_token&#45;&gt;watcher -->
<g id="edge7" class="edge">
<title>resume_token&#45;&gt;watcher</title>
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M552.48,-302.23C516.62,-302.72 473.99,-302.03 435.75,-298 433.7,-297.78 431.62,-297.53 429.53,-297.25"/>
<polygon fill="#666666" stroke="#666666" points="430.15,-293.81 419.73,-295.75 429.09,-300.73 430.15,-293.81"/>
<text xml:space="preserve" text-anchor="middle" x="468" y="-305.07" font-family="Helvetica,sans-Serif" font-size="10.00">resume_after</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,83 @@
digraph Deployment {
// Graph settings
rankdir=TB;
compound=true;
fontname="Helvetica";
node [fontname="Helvetica", fontsize=11];
edge [fontname="Helvetica", fontsize=10];
// Title
labelloc="t";
label="Deskmeter - Deployment Architecture";
fontsize=16;
// Styling
node [shape=box, style="rounded,filled"];
// Development
subgraph cluster_dev {
label="Development (Local)";
style=filled;
color="#E3F2FD";
fillcolor="#E3F2FD";
source [label="Source Code\n(/home/mariano/wdir/dm)", fillcolor="#BBDEFB"];
docker_build [label="docker build", fillcolor="#BBDEFB", shape=parallelogram];
}
// Registry
subgraph cluster_registry {
label="Private Registry";
style=filled;
color="#F3E5F5";
fillcolor="#F3E5F5";
registry [label="registry.mcrn.ar:5000\n(Docker Registry)", fillcolor="#E1BEE7", shape=cylinder];
}
// AWS EC2
subgraph cluster_aws {
label="AWS EC2 (mcrn.ar)";
style=filled;
color="#FFF3E0";
fillcolor="#FFF3E0";
// Gateway Network
subgraph cluster_gateway {
label="gateway network";
style=dashed;
color="#F57C00";
nginx [label="Nginx\n(SSL termination)", fillcolor="#FFE0B2"];
dmweb_container [label="dmweb:latest\n(port 10000)", fillcolor="#FFE0B2"];
}
// Internal Network
subgraph cluster_internal {
label="internal network";
style=dashed;
color="#388E3C";
mongo_container [label="mongo:7\n(port 27017)", fillcolor="#C8E6C9", shape=cylinder];
mongo_volume [label="mongo-data\n(volume)", fillcolor="#DCEDC8", shape=folder];
}
}
// DNS
dns [label="deskmeter.mcrn.ar\n(DNS)", fillcolor="#FFCDD2", shape=diamond];
// Internet
internet [label="Internet\n(Portfolio Visitors)", fillcolor="#F5F5F5"];
// Flow
source -> docker_build [label="Dockerfile"];
docker_build -> registry [label="docker push", color="#7B1FA2"];
registry -> dmweb_container [label="docker pull", color="#7B1FA2"];
dmweb_container -> mongo_container [label="MONGODB_HOST=mongo", color="#388E3C"];
mongo_container -> mongo_volume [label="persist", style=dashed];
internet -> dns [color="#1976D2"];
dns -> nginx [label="HTTPS", color="#1976D2"];
nginx -> dmweb_container [label="proxy_pass", color="#F57C00"];
}

View File

@@ -0,0 +1,158 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.1 (0)
-->
<!-- Title: Deployment Pages: 1 -->
<svg width="447pt" height="701pt"
viewBox="0.00 0.00 447.00 701.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 697.44)">
<title>Deployment</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-697.44 443.25,-697.44 443.25,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="219.62" y="-674.24" font-family="Helvetica,sans-Serif" font-size="16.00">Deskmeter &#45; Deployment Architecture</text>
<g id="clust1" class="cluster">
<title>cluster_dev</title>
<polygon fill="#e3f2fd" stroke="#e3f2fd" points="8,-476.69 8,-657.94 191,-657.94 191,-476.69 8,-476.69"/>
<text xml:space="preserve" text-anchor="middle" x="99.5" y="-638.74" font-family="Helvetica,sans-Serif" font-size="16.00">Development (Local)</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_registry</title>
<polygon fill="#f3e5f5" stroke="#f3e5f5" points="40,-307.88 40,-399.5 190,-399.5 190,-307.88 40,-307.88"/>
<text xml:space="preserve" text-anchor="middle" x="115" y="-380.3" font-family="Helvetica,sans-Serif" font-size="16.00">Private Registry</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_aws</title>
<polygon fill="#fff3e0" stroke="#fff3e0" points="227,-8 227,-428.94 397,-428.94 397,-8 227,-8"/>
<text xml:space="preserve" text-anchor="middle" x="312" y="-409.74" font-family="Helvetica,sans-Serif" font-size="16.00">AWS EC2 (mcrn.ar)</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_gateway</title>
<polygon fill="none" stroke="#f57c00" stroke-dasharray="5,2" points="235,-223.12 235,-393.44 389,-393.44 389,-223.12 235,-223.12"/>
<text xml:space="preserve" text-anchor="middle" x="312" y="-374.24" font-family="Helvetica,sans-Serif" font-size="16.00">gateway network</text>
</g>
<g id="clust5" class="cluster">
<title>cluster_internal</title>
<polygon fill="none" stroke="#388e3c" stroke-dasharray="5,2" points="235,-16 235,-192.38 382,-192.38 382,-16 235,-16"/>
<text xml:space="preserve" text-anchor="middle" x="308.5" y="-173.18" font-family="Helvetica,sans-Serif" font-size="16.00">internal network</text>
</g>
<!-- source -->
<g id="node1" class="node">
<title>source</title>
<path fill="#bbdefb" stroke="black" d="M171.12,-622.44C171.12,-622.44 38.88,-622.44 38.88,-622.44 32.88,-622.44 26.88,-616.44 26.88,-610.44 26.88,-610.44 26.88,-598.44 26.88,-598.44 26.88,-592.44 32.88,-586.44 38.88,-586.44 38.88,-586.44 171.12,-586.44 171.12,-586.44 177.12,-586.44 183.12,-592.44 183.12,-598.44 183.12,-598.44 183.12,-610.44 183.12,-610.44 183.12,-616.44 177.12,-622.44 171.12,-622.44"/>
<text xml:space="preserve" text-anchor="middle" x="105" y="-607.49" font-family="Helvetica,sans-Serif" font-size="11.00">Source Code</text>
<text xml:space="preserve" text-anchor="middle" x="105" y="-593.99" font-family="Helvetica,sans-Serif" font-size="11.00">(/home/mariano/wdir/dm)</text>
</g>
<!-- docker_build -->
<g id="node2" class="node">
<title>docker_build</title>
<path fill="#bbdefb" stroke="black" d="M168.63,-520.69C168.63,-520.69 72.32,-520.69 72.32,-520.69 66.32,-520.69 56.41,-516.14 52.49,-511.59 52.49,-511.59 37.19,-493.79 37.19,-493.79 33.28,-489.24 35.37,-484.69 41.37,-484.69 41.37,-484.69 137.68,-484.69 137.68,-484.69 143.68,-484.69 153.59,-489.24 157.51,-493.79 157.51,-493.79 172.81,-511.59 172.81,-511.59 176.72,-516.14 174.63,-520.69 168.63,-520.69"/>
<text xml:space="preserve" text-anchor="middle" x="105" y="-498.99" font-family="Helvetica,sans-Serif" font-size="11.00">docker build</text>
</g>
<!-- source&#45;&gt;docker_build -->
<g id="edge1" class="edge">
<title>source&#45;&gt;docker_build</title>
<path fill="none" stroke="black" d="M105,-586.06C105,-571.26 105,-549.69 105,-532.33"/>
<polygon fill="black" stroke="black" points="108.5,-532.38 105,-522.38 101.5,-532.38 108.5,-532.38"/>
<text xml:space="preserve" text-anchor="middle" x="129.38" y="-558.94" font-family="Helvetica,sans-Serif" font-size="10.00">Dockerfile</text>
</g>
<!-- registry -->
<g id="node3" class="node">
<title>registry</title>
<path fill="#e1bee7" stroke="black" d="M182.25,-359.62C182.25,-362.04 152.11,-364 115,-364 77.89,-364 47.75,-362.04 47.75,-359.62 47.75,-359.62 47.75,-320.25 47.75,-320.25 47.75,-317.84 77.89,-315.88 115,-315.88 152.11,-315.88 182.25,-317.84 182.25,-320.25 182.25,-320.25 182.25,-359.62 182.25,-359.62"/>
<path fill="none" stroke="black" d="M182.25,-359.62C182.25,-357.21 152.11,-355.25 115,-355.25 77.89,-355.25 47.75,-357.21 47.75,-359.62"/>
<text xml:space="preserve" text-anchor="middle" x="115" y="-342.99" font-family="Helvetica,sans-Serif" font-size="11.00">registry.mcrn.ar:5000</text>
<text xml:space="preserve" text-anchor="middle" x="115" y="-329.49" font-family="Helvetica,sans-Serif" font-size="11.00">(Docker Registry)</text>
</g>
<!-- docker_build&#45;&gt;registry -->
<g id="edge2" class="edge">
<title>docker_build&#45;&gt;registry</title>
<path fill="none" stroke="#7b1fa2" d="M106.07,-484.48C107.68,-458.62 110.76,-409.17 112.84,-375.63"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="116.31,-376.2 113.44,-366 109.33,-375.76 116.31,-376.2"/>
<text xml:space="preserve" text-anchor="middle" x="138.06" y="-440.19" font-family="Helvetica,sans-Serif" font-size="10.00">docker push</text>
</g>
<!-- dmweb_container -->
<g id="node5" class="node">
<title>dmweb_container</title>
<path fill="#ffe0b2" stroke="black" d="M322.5,-267.12C322.5,-267.12 255.5,-267.12 255.5,-267.12 249.5,-267.12 243.5,-261.12 243.5,-255.12 243.5,-255.12 243.5,-243.12 243.5,-243.12 243.5,-237.12 249.5,-231.12 255.5,-231.12 255.5,-231.12 322.5,-231.12 322.5,-231.12 328.5,-231.12 334.5,-237.12 334.5,-243.12 334.5,-243.12 334.5,-255.12 334.5,-255.12 334.5,-261.12 328.5,-267.12 322.5,-267.12"/>
<text xml:space="preserve" text-anchor="middle" x="289" y="-252.18" font-family="Helvetica,sans-Serif" font-size="11.00">dmweb:latest</text>
<text xml:space="preserve" text-anchor="middle" x="289" y="-238.68" font-family="Helvetica,sans-Serif" font-size="11.00">(port 10000)</text>
</g>
<!-- registry&#45;&gt;dmweb_container -->
<g id="edge3" class="edge">
<title>registry&#45;&gt;dmweb_container</title>
<path fill="none" stroke="#7b1fa2" d="M159.81,-316.07C185.94,-302.73 218.78,-285.97 244.81,-272.68"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="246.12,-275.94 253.43,-268.28 242.94,-269.71 246.12,-275.94"/>
<text xml:space="preserve" text-anchor="middle" x="244.21" y="-288.38" font-family="Helvetica,sans-Serif" font-size="10.00">docker pull</text>
</g>
<!-- nginx -->
<g id="node4" class="node">
<title>nginx</title>
<path fill="#ffe0b2" stroke="black" d="M356,-357.94C356,-357.94 268,-357.94 268,-357.94 262,-357.94 256,-351.94 256,-345.94 256,-345.94 256,-333.94 256,-333.94 256,-327.94 262,-321.94 268,-321.94 268,-321.94 356,-321.94 356,-321.94 362,-321.94 368,-327.94 368,-333.94 368,-333.94 368,-345.94 368,-345.94 368,-351.94 362,-357.94 356,-357.94"/>
<text xml:space="preserve" text-anchor="middle" x="312" y="-342.99" font-family="Helvetica,sans-Serif" font-size="11.00">Nginx</text>
<text xml:space="preserve" text-anchor="middle" x="312" y="-329.49" font-family="Helvetica,sans-Serif" font-size="11.00">(SSL termination)</text>
</g>
<!-- nginx&#45;&gt;dmweb_container -->
<g id="edge8" class="edge">
<title>nginx&#45;&gt;dmweb_container</title>
<path fill="none" stroke="#f57c00" d="M307.57,-321.81C304.37,-309.48 299.99,-292.57 296.29,-278.27"/>
<polygon fill="#f57c00" stroke="#f57c00" points="299.77,-277.77 293.88,-268.96 293,-279.52 299.77,-277.77"/>
<text xml:space="preserve" text-anchor="middle" x="328.94" y="-288.38" font-family="Helvetica,sans-Serif" font-size="10.00">proxy_pass</text>
</g>
<!-- mongo_container -->
<g id="node6" class="node">
<title>mongo_container</title>
<path fill="#c8e6c9" stroke="black" d="M328.5,-152.5C328.5,-154.91 309.45,-156.88 286,-156.88 262.55,-156.88 243.5,-154.91 243.5,-152.5 243.5,-152.5 243.5,-113.12 243.5,-113.12 243.5,-110.71 262.55,-108.75 286,-108.75 309.45,-108.75 328.5,-110.71 328.5,-113.12 328.5,-113.12 328.5,-152.5 328.5,-152.5"/>
<path fill="none" stroke="black" d="M328.5,-152.5C328.5,-150.09 309.45,-148.12 286,-148.12 262.55,-148.12 243.5,-150.09 243.5,-152.5"/>
<text xml:space="preserve" text-anchor="middle" x="286" y="-135.86" font-family="Helvetica,sans-Serif" font-size="11.00">mongo:7</text>
<text xml:space="preserve" text-anchor="middle" x="286" y="-122.36" font-family="Helvetica,sans-Serif" font-size="11.00">(port 27017)</text>
</g>
<!-- dmweb_container&#45;&gt;mongo_container -->
<g id="edge4" class="edge">
<title>dmweb_container&#45;&gt;mongo_container</title>
<path fill="none" stroke="#388e3c" d="M288.54,-230.76C288.11,-214.37 287.46,-189.3 286.92,-168.77"/>
<polygon fill="#388e3c" stroke="#388e3c" points="290.42,-168.71 286.66,-158.81 283.42,-168.9 290.42,-168.71"/>
<text xml:space="preserve" text-anchor="middle" x="351.78" y="-203.62" font-family="Helvetica,sans-Serif" font-size="10.00">MONGODB_HOST=mongo</text>
</g>
<!-- mongo_volume -->
<g id="node7" class="node">
<title>mongo_volume</title>
<polygon fill="#dcedc8" stroke="black" points="327,-60 324,-64 303,-64 300,-60 245,-60 245,-24 327,-24 327,-60"/>
<text xml:space="preserve" text-anchor="middle" x="286" y="-45.05" font-family="Helvetica,sans-Serif" font-size="11.00">mongo&#45;data</text>
<text xml:space="preserve" text-anchor="middle" x="286" y="-31.55" font-family="Helvetica,sans-Serif" font-size="11.00">(volume)</text>
</g>
<!-- mongo_container&#45;&gt;mongo_volume -->
<g id="edge5" class="edge">
<title>mongo_container&#45;&gt;mongo_volume</title>
<path fill="none" stroke="black" stroke-dasharray="5,2" d="M286,-108.48C286,-97.15 286,-83.43 286,-71.49"/>
<polygon fill="black" stroke="black" points="289.5,-71.8 286,-61.8 282.5,-71.8 289.5,-71.8"/>
<text xml:space="preserve" text-anchor="middle" x="302.5" y="-81.25" font-family="Helvetica,sans-Serif" font-size="10.00">persist</text>
</g>
<!-- dns -->
<g id="node8" class="node">
<title>dns</title>
<path fill="#ffcdd2" stroke="black" d="M307.48,-534.33C307.48,-534.33 210.27,-506.04 210.27,-506.04 204.51,-504.36 204.51,-501.01 210.27,-499.33 210.27,-499.33 307.48,-471.04 307.48,-471.04 313.24,-469.36 324.76,-469.36 330.52,-471.04 330.52,-471.04 427.73,-499.33 427.73,-499.33 433.49,-501.01 433.49,-504.36 427.73,-506.04 427.73,-506.04 330.52,-534.33 330.52,-534.33 324.76,-536.01 313.24,-536.01 307.48,-534.33"/>
<text xml:space="preserve" text-anchor="middle" x="319" y="-505.74" font-family="Helvetica,sans-Serif" font-size="11.00">deskmeter.mcrn.ar</text>
<text xml:space="preserve" text-anchor="middle" x="319" y="-492.24" font-family="Helvetica,sans-Serif" font-size="11.00">(DNS)</text>
</g>
<!-- dns&#45;&gt;nginx -->
<g id="edge7" class="edge">
<title>dns&#45;&gt;nginx</title>
<path fill="none" stroke="#1976d2" d="M317.53,-467.99C316.26,-438.8 314.44,-397.03 313.24,-369.47"/>
<polygon fill="#1976d2" stroke="#1976d2" points="316.75,-369.6 312.82,-359.76 309.76,-369.9 316.75,-369.6"/>
<text xml:space="preserve" text-anchor="middle" x="332.29" y="-440.19" font-family="Helvetica,sans-Serif" font-size="10.00">HTTPS</text>
</g>
<!-- internet -->
<g id="node9" class="node">
<title>internet</title>
<path fill="#f5f5f5" stroke="black" d="M364.5,-622.44C364.5,-622.44 273.5,-622.44 273.5,-622.44 267.5,-622.44 261.5,-616.44 261.5,-610.44 261.5,-610.44 261.5,-598.44 261.5,-598.44 261.5,-592.44 267.5,-586.44 273.5,-586.44 273.5,-586.44 364.5,-586.44 364.5,-586.44 370.5,-586.44 376.5,-592.44 376.5,-598.44 376.5,-598.44 376.5,-610.44 376.5,-610.44 376.5,-616.44 370.5,-622.44 364.5,-622.44"/>
<text xml:space="preserve" text-anchor="middle" x="319" y="-607.49" font-family="Helvetica,sans-Serif" font-size="11.00">Internet</text>
<text xml:space="preserve" text-anchor="middle" x="319" y="-593.99" font-family="Helvetica,sans-Serif" font-size="11.00">(Portfolio Visitors)</text>
</g>
<!-- internet&#45;&gt;dns -->
<g id="edge6" class="edge">
<title>internet&#45;&gt;dns</title>
<path fill="none" stroke="#1976d2" d="M319,-586.06C319,-575.87 319,-562.48 319,-549.46"/>
<polygon fill="#1976d2" stroke="#1976d2" points="322.5,-549.72 319,-539.72 315.5,-549.72 322.5,-549.72"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Graph Viewer - Deskmeter</title>
<link rel="stylesheet" href="styles.css">
</head>
<body class="graph-viewer">
<header class="graph-header">
<a href="index.html" class="back-link">← Index</a>
<div class="nav-controls">
<button onclick="navigate(-1)" id="btn-prev" title="Previous (←)"></button>
<span id="nav-position">1 / 3</span>
<button onclick="navigate(1)" id="btn-next" title="Next (→)"></button>
</div>
<h1 id="graph-title">Loading...</h1>
<div class="graph-controls">
<button onclick="setMode('fit')">Fit</button>
<button onclick="setMode('fit-width')">Width</button>
<button onclick="setMode('fit-height')">Height</button>
<button onclick="setMode('actual-size')">100%</button>
<button onclick="downloadSvg()">↓ SVG</button>
</div>
</header>
<div class="graph-container" id="graph-container">
<img id="graph-img" src="" alt="Graph">
</div>
<script>
const graphOrder = [
'01-system-overview',
'02-data-sync',
'03-deployment'
];
const graphs = {
'01-system-overview': {
title: 'System Overview',
file: '01-system-overview.svg'
},
'02-data-sync': {
title: 'Data Sync Architecture',
file: '02-data-sync.svg'
},
'03-deployment': {
title: 'Deployment Architecture',
file: '03-deployment.svg'
}
};
const params = new URLSearchParams(window.location.search);
let graphKey = params.get('g') || '01-system-overview';
let currentIndex = graphOrder.indexOf(graphKey);
if (currentIndex === -1) currentIndex = 0;
function loadGraph(key) {
const graph = graphs[key];
document.getElementById('graph-title').textContent = graph.title;
document.getElementById('graph-img').src = graph.file;
document.title = graph.title + ' - Deskmeter';
history.replaceState(null, '', '?g=' + key);
graphKey = key;
updateNavHints();
}
function updateNavHints() {
const idx = graphOrder.indexOf(graphKey);
const prevBtn = document.getElementById('btn-prev');
const nextBtn = document.getElementById('btn-next');
prevBtn.disabled = idx === 0;
nextBtn.disabled = idx === graphOrder.length - 1;
document.getElementById('nav-position').textContent = (idx + 1) + ' / ' + graphOrder.length;
}
function navigate(direction) {
const idx = graphOrder.indexOf(graphKey);
const newIdx = idx + direction;
if (newIdx >= 0 && newIdx < graphOrder.length) {
currentIndex = newIdx;
loadGraph(graphOrder[newIdx]);
}
}
function setMode(mode) {
const container = document.getElementById('graph-container');
container.className = 'graph-container ' + mode;
}
function downloadSvg() {
const graph = graphs[graphKey];
const link = document.createElement('a');
link.href = graph.file;
link.download = graph.file;
link.click();
}
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') {
navigate(-1);
} else if (e.key === 'ArrowRight') {
navigate(1);
} else if (e.key === 'Escape') {
window.location.href = 'index.html';
}
});
// Initialize
loadGraph(graphOrder[currentIndex]);
setMode('fit');
</script>
</body>
</html>

View File

@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deskmeter - Architecture & Design</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header>
<h1>Deskmeter</h1>
<p class="subtitle">Productivity Tracking - Architecture Documentation</p>
</header>
<main>
<section class="graph-section" id="overview">
<div class="graph-header-row">
<h2>System Overview</h2>
<a href="graph.html?g=01-system-overview" class="view-btn">View Full</a>
</div>
<a href="graph.html?g=01-system-overview" class="graph-preview">
<img src="01-system-overview.svg" alt="System Overview">
</a>
<div class="graph-details">
<p>High-level architecture showing local tracking, data sync, and remote portfolio deployment.</p>
<h4>Key Components</h4>
<ul>
<li><strong>dmcore</strong>: Workspace tracking daemon using wmctrl (X11)</li>
<li><strong>dmweb</strong>: Flask web dashboard with calendar and switches views</li>
<li><strong>dmsync</strong>: Change Streams daemon pushing data to remote MongoDB</li>
<li><strong>GNOME Extension</strong>: Panel indicator showing current task</li>
</ul>
</div>
</section>
<section class="graph-section" id="data-sync">
<div class="graph-header-row">
<h2>Data Sync Architecture</h2>
<a href="graph.html?g=02-data-sync" class="view-btn">View Full</a>
</div>
<a href="graph.html?g=02-data-sync" class="graph-preview">
<img src="02-data-sync.svg" alt="Data Sync">
</a>
<div class="graph-details">
<p>Real-time sync using MongoDB Change Streams with resume token persistence.</p>
<h4>Collections Synced</h4>
<table class="details-table">
<thead>
<tr>
<th>Collection</th>
<th>Purpose</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>switch</code></td>
<td>Workspace switch events with timestamps and durations</td>
<td>~33k docs</td>
</tr>
<tr>
<td><code>task</code></td>
<td>Current task definitions from task file</td>
<td>~50 docs</td>
</tr>
<tr>
<td><code>task_history</code></td>
<td>Cached historic task path lookups</td>
<td>~200 docs</td>
</tr>
<tr>
<td><code>state</code></td>
<td>Current workspace and task state</td>
<td>1 doc</td>
</tr>
</tbody>
</table>
<h4>Why Change Streams?</h4>
<ul>
<li>Reacts to actual changes (not polling)</li>
<li>Resume token ensures no data loss on restart</li>
<li>Native MongoDB feature (requires replica set)</li>
<li>Clean, intentional architecture for portfolio</li>
</ul>
</div>
</section>
<section class="graph-section" id="deployment">
<div class="graph-header-row">
<h2>Deployment Architecture</h2>
<a href="graph.html?g=03-deployment" class="view-btn">View Full</a>
</div>
<a href="graph.html?g=03-deployment" class="graph-preview">
<img src="03-deployment.svg" alt="Deployment">
</a>
<div class="graph-details">
<p>Docker Compose deployment on AWS EC2 with Nginx reverse proxy.</p>
<h4>Networks</h4>
<ul>
<li><strong>gateway</strong>: External network connecting Nginx to dmweb</li>
<li><strong>internal</strong>: Private network for dmweb ↔ MongoDB communication</li>
</ul>
<h4>Deployment Flow</h4>
<ul>
<li>Build Docker image locally</li>
<li>Push to private registry (registry.mcrn.ar)</li>
<li>Pull and deploy on AWS EC2</li>
</ul>
</div>
</section>
<section class="findings-section">
<h2>Design Decisions</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Bare Metal Core</h3>
<p>dmcore runs on bare metal (not Docker) because it requires direct OS access for workspace detection via wmctrl and X11.</p>
</article>
<article class="finding-card">
<h3>Single-Node Replica Set</h3>
<p>Local MongoDB configured as replica set (rs0) to enable Change Streams. No actual replication, just API requirement.</p>
</article>
<article class="finding-card">
<h3>Push Sync Model</h3>
<p>Local machine pushes to remote (not pull). MongoDB on remote is not exposed to internet - only accessible from the sync daemon.</p>
</article>
<article class="finding-card">
<h3>Portfolio-Ready</h3>
<p>Remote instance shows historical data. "Current task" reflects last-synced state, not real-time.</p>
</article>
</div>
</section>
<section class="tech-section">
<h2>Technology Stack</h2>
<div class="tech-grid">
<div class="tech-column">
<h3>Core</h3>
<ul>
<li>Python 3.11</li>
<li>Flask</li>
<li>PyMongo</li>
<li>Jinja2 Templates</li>
</ul>
</div>
<div class="tech-column">
<h3>Data</h3>
<ul>
<li>MongoDB 7</li>
<li>Change Streams</li>
<li>Replica Set</li>
</ul>
</div>
<div class="tech-column">
<h3>Infrastructure</h3>
<ul>
<li>Docker</li>
<li>Docker Compose</li>
<li>Nginx</li>
<li>AWS EC2</li>
</ul>
</div>
<div class="tech-column">
<h3>OS Integration</h3>
<ul>
<li>wmctrl (X11)</li>
<li>GNOME Shell Extension</li>
<li>systemd</li>
</ul>
</div>
</div>
</section>
</main>
<footer>
<p>Deskmeter - Architecture Documentation</p>
<p class="date">Generated: <time datetime="2025-01">January 2025</time></p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,348 @@
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-card: #0f3460;
--text-primary: #eee;
--text-secondary: #a0a0a0;
--accent: #e94560;
--accent-secondary: #533483;
--border: #2a2a4a;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
header {
background: linear-gradient(135deg, var(--bg-secondary), var(--accent-secondary));
padding: 2rem;
text-align: center;
border-bottom: 2px solid var(--accent);
}
header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
header .subtitle {
color: var(--text-secondary);
font-size: 1rem;
}
main {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Graph sections */
.graph-section {
background: var(--bg-secondary);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
border: 1px solid var(--border);
}
.graph-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.graph-header-row h2 {
font-size: 1.25rem;
color: var(--accent);
}
.view-btn {
background: var(--accent);
color: white;
padding: 0.5rem 1rem;
border-radius: 4px;
text-decoration: none;
font-size: 0.875rem;
transition: opacity 0.2s;
}
.view-btn:hover {
opacity: 0.8;
}
.graph-preview {
display: block;
background: white;
border-radius: 4px;
padding: 1rem;
margin-bottom: 1rem;
overflow: auto;
max-height: 400px;
}
.graph-preview img {
max-width: 100%;
height: auto;
}
.graph-details {
color: var(--text-secondary);
font-size: 0.9rem;
}
.graph-details h4 {
color: var(--text-primary);
margin: 1rem 0 0.5rem;
}
.graph-details ul {
margin-left: 1.5rem;
}
.graph-details li {
margin-bottom: 0.25rem;
}
/* Tech section */
.tech-section {
background: var(--bg-secondary);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
border: 1px solid var(--border);
}
.tech-section h2 {
color: var(--accent);
margin-bottom: 1rem;
}
.tech-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
}
.tech-column h3 {
color: var(--text-primary);
font-size: 1rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.tech-column ul {
list-style: none;
}
.tech-column li {
padding: 0.25rem 0;
color: var(--text-secondary);
}
/* Findings */
.findings-section {
margin-bottom: 2rem;
}
.findings-section h2 {
color: var(--accent);
margin-bottom: 1rem;
}
.findings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.finding-card {
background: var(--bg-secondary);
border-radius: 8px;
padding: 1.25rem;
border: 1px solid var(--border);
}
.finding-card h3 {
color: var(--accent);
font-size: 1rem;
margin-bottom: 0.75rem;
}
.finding-card p {
color: var(--text-secondary);
font-size: 0.9rem;
}
.finding-card ul {
margin-left: 1rem;
color: var(--text-secondary);
}
.finding-card code {
background: var(--bg-primary);
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.85em;
}
/* Footer */
footer {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
border-top: 1px solid var(--border);
}
footer .date {
font-size: 0.85rem;
}
/* Graph viewer page */
body.graph-viewer {
display: flex;
flex-direction: column;
height: 100vh;
}
.graph-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.back-link {
color: var(--accent);
text-decoration: none;
}
.nav-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-controls button {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 0.25rem 0.75rem;
border-radius: 4px;
cursor: pointer;
}
.nav-controls button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
#nav-position {
color: var(--text-secondary);
font-size: 0.85rem;
}
.graph-header h1 {
flex: 1;
font-size: 1rem;
text-align: center;
}
.graph-controls {
display: flex;
gap: 0.5rem;
}
.graph-controls button {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 0.375rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.graph-controls button:hover {
background: var(--accent);
}
.graph-container {
flex: 1;
overflow: auto;
background: white;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 1rem;
}
.graph-container.fit img {
max-width: 100%;
max-height: calc(100vh - 60px);
object-fit: contain;
}
.graph-container.fit-width img {
width: 100%;
height: auto;
}
.graph-container.fit-height img {
height: calc(100vh - 60px);
width: auto;
}
.graph-container.actual-size img {
/* No constraints */
}
/* Tables */
.details-table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
font-size: 0.85rem;
}
.details-table th,
.details-table td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.details-table th {
color: var(--text-primary);
background: var(--bg-primary);
}
.details-table td {
color: var(--text-secondary);
}
.details-table code {
background: var(--bg-primary);
padding: 0.125rem 0.375rem;
border-radius: 3px;
}
.note {
font-style: italic;
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: 0.5rem;
}

View File

@@ -1,119 +0,0 @@
# 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 updates automatically when you switch workspaces. It waits 2.2 seconds after a workspace switch to allow dmcore (which polls every 2 seconds) to detect the change and update MongoDB.
You can adjust the delay in `extension.js`:
```javascript
const DEBOUNCE_DELAY = 2200; // 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.
## Updating the Extension
After making changes to the extension code:
```bash
# Use the update script
cd /home/mariano/wdir/dm/gnome-extension
./update.sh
# Then restart GNOME Shell (X11 only)
Alt+F2, type: r, press Enter
# On Wayland: log out and back in
```
Or manually:
```bash
cp -r /home/mariano/wdir/dm/gnome-extension/deskmeter-indicator@local \
~/.local/share/gnome-shell/extensions/
# 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
```
3. Try disabling and re-enabling:
```bash
gnome-extensions disable deskmeter-indicator@local
gnome-extensions enable deskmeter-indicator@local
```
### 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"}`
### Changes not appearing
- Make sure you copied files after editing
- GNOME Shell must be restarted (no way around this)
- Check logs for JavaScript errors: `journalctl -b -o cat /usr/bin/gnome-shell | grep deskmeter`
### Debug with Looking Glass
Press `Alt+F2`, type `lg`, press Enter. Go to Extensions tab to see if the extension loaded and check for errors.
### Task path too long
The extension automatically truncates paths longer than 40 characters, showing only the last two segments with a `.../ ` prefix.

View File

@@ -1,132 +0,0 @@
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 DEBOUNCE_DELAY = 2200; // Wait 2.2s after workspace switch (dmcore polls every 2s)
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);
this._debounceTimeout = null;
this._workspaceManager = global.workspace_manager;
// Connect to workspace switch signal
this._workspaceSwitchedId = this._workspaceManager.connect(
'workspace-switched',
this._onWorkspaceSwitched.bind(this)
);
// Initial update
this._scheduleUpdate();
}
_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();
}
_scheduleUpdate() {
// 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;
});
}
_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._debounceTimeout) {
GLib.source_remove(this._debounceTimeout);
this._debounceTimeout = null;
}
if (this._workspaceSwitchedId) {
this._workspaceManager.disconnect(this._workspaceSwitchedId);
this._workspaceSwitchedId = 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;
}
}
}

22
run_task_window.sh Normal file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Launcher for Deskmeter Task Window with always-on-top and sticky properties
# Start the task window in background
python3 task_window.py &
TASK_PID=$!
# Wait for window to appear
sleep 2
# Find the window and set properties
# Make it always on top and visible on all workspaces (sticky)
wmctrl -r "Deskmeter Task" -b add,above,sticky
echo "Task window started (PID: $TASK_PID)"
echo "Window set to: always-on-top + visible on all workspaces"
echo ""
echo "To close: kill $TASK_PID"
echo "Or just close the window normally"
# Keep script running to show PID
wait $TASK_PID