From 23b43418422cb84baab54561ad5f1c4693d95753 Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Fri, 19 Dec 2025 22:47:38 -0300 Subject: [PATCH] major fm updates --- dmapp/dmweb/dm.py | 14 ++- dmapp/dmweb/get_period_times.py | 112 ++++++++++++--------- dmapp/dmweb/templates/calendar_view.html | 123 +++++++++++++++-------- dmapp/dmweb/templates/layout.html | 9 +- dmapp/dmweb/templates/main.html | 6 +- dmapp/dmweb/templates/main_content.html | 4 +- dmapp/dmweb/templates/switches_view.html | 45 ++++++--- 7 files changed, 197 insertions(+), 116 deletions(-) diff --git a/dmapp/dmweb/dm.py b/dmapp/dmweb/dm.py index a0e611f..c3feb41 100644 --- a/dmapp/dmweb/dm.py +++ b/dmapp/dmweb/dm.py @@ -19,7 +19,12 @@ def calendar_view(scope="daily", year=None, month=None, day=None): """ Google Calendar-style view showing task blocks at their actual times. """ + from flask import request + task = None + grid = int(request.args.get('grid', 1)) # Grid hours: 1, 3, or 6 + if grid not in [1, 3, 6]: + grid = 1 if not year: year = datetime.today().year @@ -33,7 +38,7 @@ def calendar_view(scope="daily", year=None, month=None, day=None): 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) + blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=60, grid_hours=grid) prev_date = base_date - timedelta(days=1) next_date = base_date + timedelta(days=1) days = [base_date] @@ -41,7 +46,7 @@ def calendar_view(scope="daily", year=None, month=None, day=None): 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) + blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=300, grid_hours=grid) prev_date = start - timedelta(days=7) next_date = start + timedelta(days=7) days = [start + timedelta(days=i) for i in range(7)] @@ -52,7 +57,7 @@ def calendar_view(scope="daily", year=None, month=None, day=None): 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) + blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=600, grid_hours=grid) if month == 1: prev_date = datetime(year - 1, 12, 1, tzinfo=timezone) else: @@ -70,7 +75,7 @@ def calendar_view(scope="daily", year=None, month=None, day=None): 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) + blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=60, grid_hours=grid) prev_date = base_date - timedelta(days=1) next_date = base_date + timedelta(days=1) days = [base_date] @@ -85,6 +90,7 @@ def calendar_view(scope="daily", year=None, month=None, day=None): prev_date=prev_date, next_date=next_date, days=days, + grid=grid, auto_refresh=False ) diff --git a/dmapp/dmweb/get_period_times.py b/dmapp/dmweb/get_period_times.py index 9b166bb..66cca31 100644 --- a/dmapp/dmweb/get_period_times.py +++ b/dmapp/dmweb/get_period_times.py @@ -238,85 +238,99 @@ def get_work_period_totals(start, end): 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): """ - Get task blocks for calendar-style visualization. - Groups consecutive switches to the same task into blocks, tracking active/idle time. + Get task blocks for calendar-style visualization, aggregated by time grid. + 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: [{ '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) + 'start': datetime (start of grid period), + 'end': datetime (end of grid period or actual end time if less), + 'duration': int (seconds in this grid block), + 'hour': int (hour of grid start, 0-23), + 'active_seconds': int, + 'active_ratio': float (always 1.0) }, ...] """ 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: 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)) if not raw_switches: return [] - blocks = [] - current_block = None + # Aggregate by grid period and task + # Structure: {(date, grid_start_hour, task_id): total_seconds} + grid_task_time = defaultdict(lambda: {"duration": 0, "task_path": 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"] + # 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 - if current_block is None or current_block["task_id"] != task_id: - if current_block is not None: - blocks.append(current_block) + while remaining_duration > 0 and current_time < switch_end: + # Calculate grid period start (hour rounded down to grid_hours) + grid_hour = (current_time.hour // grid_hours) * grid_hours + grid_start = current_time.replace(hour=grid_hour, minute=0, second=0, microsecond=0) + grid_end = grid_start + timedelta(hours=grid_hours) - # Get task path with history fallback - task_path = get_task_path(task_id) or "No Task" + # Time in this grid period + time_in_grid = min( + (grid_end - current_time).total_seconds(), + remaining_duration + ) - current_block = { + 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" + grid_task_time[key]["task_path"] = task_path + + 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=timezone) + + blocks.append({ "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 + "task_path": data["task_path"], + "start": grid_start, + "end": grid_start + timedelta(seconds=data["duration"]), + "hour": grid_hour, + "duration": int(data["duration"]), + "active_seconds": int(data["duration"]), + "idle_seconds": 0, + "active_ratio": 1.0 + }) - # 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 + return sorted(blocks, key=lambda x: (x["start"], x["task_path"])) def get_raw_switches(start, end, task=None): diff --git a/dmapp/dmweb/templates/calendar_view.html b/dmapp/dmweb/templates/calendar_view.html index 2ea9f33..a386060 100644 --- a/dmapp/dmweb/templates/calendar_view.html +++ b/dmapp/dmweb/templates/calendar_view.html @@ -5,28 +5,29 @@ body { margin: 20px; font-family: monospace; - background: #fff; + background: #1a1a1a; + color: #e0e0e0; } .nav-tabs { display: flex; gap: 20px; margin-bottom: 20px; - border-bottom: 2px solid #333; + border-bottom: 2px solid #444; padding-bottom: 10px; } .nav-tabs a { text-decoration: none; - color: #666; + color: #888; font-size: 16pt; padding: 5px 10px; } .nav-tabs a.active { - color: #000; + color: #e0e0e0; font-weight: bold; - border-bottom: 3px solid #000; + border-bottom: 3px solid #6b9bd1; } .date-nav { @@ -39,33 +40,37 @@ .date-nav a { text-decoration: none; - color: #2563eb; + color: #6b9bd1; font-size: 18pt; } .date-info { font-weight: bold; + color: #e0e0e0; } .calendar-grid { display: flex; - border: 1px solid #ddd; + border: 1px solid #333; min-height: 600px; + background: #1a1a1a; } .time-column { width: 60px; - border-right: 1px solid #ddd; - background: #f9f9f9; + border-right: 1px solid #333; + background: #222; } .time-slot { - height: 60px; - border-bottom: 1px solid #eee; + border-bottom: 1px solid #2a2a2a; padding: 5px; font-size: 10pt; color: #666; text-align: right; + display: flex; + align-items: flex-start; + justify-content: flex-end; } .days-grid { @@ -75,8 +80,9 @@ .day-column { flex: 1; - border-right: 1px solid #ddd; + border-right: 1px solid #333; position: relative; + background: #1a1a1a; } .day-column:last-child { @@ -85,13 +91,14 @@ .day-header { height: 40px; - border-bottom: 2px solid #ddd; - background: #f0f0f0; + border-bottom: 2px solid #444; + background: #2a2a2a; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 11pt; + color: #e0e0e0; } .day-grid { @@ -104,13 +111,11 @@ left: 0; right: 0; height: 60px; - border-bottom: 1px solid #eee; + border-bottom: 1px solid #2a2a2a; } .task-block { position: absolute; - left: 2px; - right: 2px; border-radius: 4px; padding: 4px; font-size: 9pt; @@ -118,13 +123,14 @@ overflow: hidden; cursor: pointer; transition: all 0.2s; - 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 { z-index: 100; transform: scale(1.05); - box-shadow: 0 4px 8px rgba(0,0,0,0.3); + box-shadow: 0 4px 12px rgba(0,0,0,0.7); } .task-label { @@ -140,8 +146,8 @@ .block-tooltip { display: none; position: absolute; - background: #333; - color: white; + background: #2a2a2a; + color: #e0e0e0; padding: 8px 12px; border-radius: 4px; font-size: 10pt; @@ -151,6 +157,8 @@ left: 100%; top: 0; margin-left: 10px; + border: 1px solid #444; + box-shadow: 0 4px 12px rgba(0,0,0,0.5); } .task-block:hover .block-tooltip { @@ -162,19 +170,26 @@ {% block content %}
- + {% if scope == 'daily' %} {{ base_date.strftime('%Y-%m-%d %A') }} {% elif scope == 'weekly' %} @@ -182,14 +197,14 @@ {% elif scope == 'monthly' %} {{ base_date.strftime('%B %Y') }} {% endif %} - +
- {% for hour in range(24) %} -
{{ '%02d:00'|format(hour) }}
+ {% for hour in range(0, 24, grid) %} +
{{ '%02d:00'|format(hour) }}
{% endfor %}
@@ -200,40 +215,60 @@ {{ day.strftime('%a %d') if scope != 'daily' else day.strftime('%A') }}
- {% for hour in range(24) %} -
+ {% for hour in range(0, 24, grid) %} +
{% endfor %} + {% set day_blocks = [] %} {% for block in blocks %} {% if block.start.date() == day.date() %} - {% set start_hour = block.start.hour + block.start.minute / 60.0 %} + {% set _ = day_blocks.append(block) %} + {% endif %} + {% endfor %} + + {# Group blocks by hour for stacking #} + {% 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 %} + + {# Render blocks stacked within each hour #} + {% 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 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 %} + {% set active_color = 'hsl(%d, 60%%, 45%%)'|format(base_color_hue) %} - {# 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) %} + {# Cascade effect: shift right and down for overlapping blocks #} + {# Each block shifts 8% right and 4px down #} + {% 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 %}
+ left: {{ offset_pct }}%; width: {{ width_pct }}%; + background: {{ active_color }}; opacity: 0.9;">
{{ block.task_path }}
-
{{ block.start.strftime('%H:%M') }} ({{ (block.duration // 60)|int }}m)
+
{{ (block.duration // 60)|int }}m
{{ block.task_path }}
- {{ block.start.strftime('%H:%M') }} - {{ block.end.strftime('%H:%M') }}
- Duration: {{ (block.duration // 60)|int }} minutes
- Active: {{ (block.active_seconds // 60)|int }}m ({{ active_pct }}%)
- Idle: {{ (block.idle_seconds // 60)|int }}m + Hour: {{ '%02d:00'|format(block.hour) }}
+ Duration: {{ (block.duration // 60)|int }} minutes
- {% endif %} + {% endfor %} {% endfor %}
diff --git a/dmapp/dmweb/templates/layout.html b/dmapp/dmweb/templates/layout.html index cc82ec6..ce4903d 100644 --- a/dmapp/dmweb/templates/layout.html +++ b/dmapp/dmweb/templates/layout.html @@ -9,6 +9,13 @@ {% endif %} + + {% block head %} {% endblock %} @@ -21,6 +28,6 @@ - + \ No newline at end of file diff --git a/dmapp/dmweb/templates/main.html b/dmapp/dmweb/templates/main.html index 8ac1bc0..88c25b0 100644 --- a/dmapp/dmweb/templates/main.html +++ b/dmapp/dmweb/templates/main.html @@ -13,14 +13,16 @@ justify-content: center; height: 100vh; /* This ensures that the container takes the full height of the viewport */ margin: 0; /* Remove default margin */ + background-color: #1a1a1a; + color: #e0e0e0; } .grey { - color:grey; + color: #888; } .blue { - color:blue; + color: #6b9bd1; } table { diff --git a/dmapp/dmweb/templates/main_content.html b/dmapp/dmweb/templates/main_content.html index 651b1e6..dc5724d 100644 --- a/dmapp/dmweb/templates/main_content.html +++ b/dmapp/dmweb/templates/main_content.html @@ -1,7 +1,7 @@ {% if current_task_path and current_task_time %}
-
{{ current_task_path }}
-
{{ current_task_time }}
+
{{ current_task_path }}
+
{{ current_task_time }}
{% endif %} diff --git a/dmapp/dmweb/templates/switches_view.html b/dmapp/dmweb/templates/switches_view.html index b1a7ee2..c54008d 100644 --- a/dmapp/dmweb/templates/switches_view.html +++ b/dmapp/dmweb/templates/switches_view.html @@ -5,27 +5,29 @@ body { margin: 20px; font-family: monospace; + background-color: #1a1a1a; + color: #e0e0e0; } .nav-tabs { display: flex; gap: 20px; margin-bottom: 20px; - border-bottom: 2px solid #333; + border-bottom: 2px solid #444; padding-bottom: 10px; } .nav-tabs a { text-decoration: none; - color: #666; + color: #888; font-size: 16pt; padding: 5px 10px; } .nav-tabs a.active { - color: #000; + color: #e0e0e0; font-weight: bold; - border-bottom: 3px solid #000; + border-bottom: 3px solid #6b9bd1; } .date-nav { @@ -38,18 +40,19 @@ .date-nav a { text-decoration: none; - color: #2563eb; + color: #6b9bd1; font-size: 18pt; } .date-info { font-weight: bold; + color: #e0e0e0; } .switches-container { display: flex; flex-direction: column; - gap: 4px; + gap: 2px; } .switch-row { @@ -62,35 +65,37 @@ .switch-time { width: 120px; - color: #666; + color: #aaa; font-weight: bold; } .switch-workspace { width: 80px; font-weight: bold; + color: #e0e0e0; } .switch-task { flex: 1; - color: #333; + color: #ccc; } .switch-duration { width: 100px; text-align: right; - color: #666; + color: #aaa; } /* Active vs idle workspace indicator */ .ws-active { font-weight: bold; } - .ws-idle { opacity: 0.6; } + .ws-idle { opacity: 0.5; } .stats { margin: 20px 0; padding: 15px; - background: #f0f0f0; + background: #2a2a2a; border-radius: 4px; + border: 1px solid #333; } .stats-row { @@ -105,11 +110,12 @@ } .stat-label { - color: #666; + color: #999; } .stat-value { font-weight: bold; + color: #e0e0e0; } {% endblock head %} @@ -156,14 +162,19 @@

All Switches ({{ switches|length }})

+ {% set max_delta = switches|map(attribute='delta')|max if switches else 1 %} + {% set base_height = 30 %} + {% set max_height = 200 %} {% 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) %} + {% set bg_color = 'hsl(%d, 30%%, 20%%)'|format(border_hue) %} + {% set height_ratio = (switch.delta / max_delta) if max_delta > 0 else 0 %} + {% set cell_height = (base_height + (height_ratio * (max_height - base_height)))|int %}
+ style="border-left-color: {{ border_color }}; background-color: {{ bg_color }}; height: {{ cell_height }}px;">
{{ switch.date.strftime('%m/%d %H:%M:%S') }}
{{ switch.workspace }}
{{ switch.task_path }}
@@ -182,4 +193,10 @@

No switches in this period

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