Merge branch 'fend-updates'

This commit is contained in:
buenosairesam
2025-12-19 22:58:27 -03:00
7 changed files with 197 additions and 116 deletions

View File

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

View File

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

View File

@@ -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 %}
<div class="nav-tabs">
<a href="/calendar/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
<a href="/calendar/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid={{ grid }}"
class="{% if scope == 'daily' %}active{% endif %}">Daily</a>
<a href="/calendar/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
<a href="/calendar/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid={{ grid }}"
class="{% if scope == 'weekly' %}active{% endif %}">Weekly</a>
<a href="/calendar/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
<a href="/calendar/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid={{ grid }}"
class="{% if scope == 'monthly' %}active{% endif %}">Monthly</a>
<span style="margin-left: auto;">
<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"
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"
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"
class="{% if grid == 6 %}active{% endif %}" style="font-size: 11pt;">6h</a>
<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>
<a href="/calendar/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}?grid={{ grid }}"></a>
{% if scope == 'daily' %}
<span class="date-info">{{ base_date.strftime('%Y-%m-%d %A') }}</span>
{% elif scope == 'weekly' %}
@@ -182,14 +197,14 @@
{% 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>
<a href="/calendar/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}?grid={{ grid }}"></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>
{% for hour in range(0, 24, grid) %}
<div class="time-slot" style="height: {{ grid * 60 }}px;">{{ '%02d:00'|format(hour) }}</div>
{% endfor %}
</div>
@@ -200,40 +215,60 @@
{{ 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>
{% for hour in range(0, 24, grid) %}
<div 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 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 %}
<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%);">
left: {{ offset_pct }}%; width: {{ width_pct }}%;
background: {{ active_color }}; opacity: 0.9;">
<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">{{ (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
Hour: {{ '%02d:00'|format(block.hour) }}<br>
Duration: {{ (block.duration // 60)|int }} minutes
</div>
</div>
{% endif %}
{% endfor %}
{% endfor %}
</div>
</div>

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{% 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 style="color: #e0e0e0;">{{ current_task_path }}</div>
<div style="color: #999; font-size: 36pt;">{{ current_task_time }}</div>
</div>
{% endif %}
<table>

View File

@@ -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;
}
</style>
{% endblock head %}
@@ -156,14 +162,19 @@
<h3>All Switches ({{ switches|length }})</h3>
<div class="switches-container">
{% 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 %}
<div class="switch-row {{ 'ws-active' if is_active else 'ws-idle' }}"
style="border-left-color: {{ border_color }}; background-color: {{ bg_color }};">
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-task">{{ switch.task_path }}</div>
@@ -182,4 +193,10 @@
<p style="text-align: center; color: #666; margin-top: 40px;">No switches in this period</p>
{% endif %}
<style>
h3 {
color: #e0e0e0;
}
</style>
{% endblock content %}