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 %}