Compare commits

...

2 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
5 changed files with 207 additions and 116 deletions

View File

@@ -11,7 +11,7 @@ import logging
import os import os
from pathlib import Path from pathlib import Path
from pymongo import MongoClient from pymongo import MongoClient, ReplaceOne
from pymongo.errors import PyMongoError from pymongo.errors import PyMongoError
logging.basicConfig( logging.basicConfig(
@@ -43,6 +43,35 @@ def save_resume_token(token):
log.error(f"Failed to save resume token: {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(): def sync():
"""Main sync loop using Change Streams.""" """Main sync loop using Change Streams."""
log.info(f"Connecting to local MongoDB...") log.info(f"Connecting to local MongoDB...")
@@ -54,12 +83,19 @@ def sync():
local_db = local.deskmeter local_db = local.deskmeter
remote_db = remote.deskmeter remote_db = remote.deskmeter
resume_token = load_resume_token() # Bulk sync first to catch up
if resume_token: log.info("Performing bulk sync to catch up...")
log.info("Resuming from saved token") synced = bulk_sync(local_db, remote_db)
log.info(f"Bulk sync complete: {synced} documents")
watch_kwargs = {"resume_after": resume_token} if resume_token else {} # Clear resume token to start fresh with Change Streams
watch_kwargs["full_document"] = "updateLookup" # Get full doc on updates # (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 # Watch for inserts, updates, and replaces on the database
pipeline = [{"$match": {"operationType": {"$in": ["insert", "update", "replace"]}}}] pipeline = [{"$match": {"operationType": {"$in": ["insert", "update", "replace"]}}}]
@@ -83,8 +119,8 @@ def sync():
{"_id": doc["_id"]}, doc, upsert=True {"_id": doc["_id"]}, doc, upsert=True
) )
action = "inserted" if result.upserted_id else "updated" if result.upserted_id:
log.info(f"{collection}: {action} {doc['_id']}") log.info(f"{collection}: inserted {doc['_id']}")
save_resume_token(stream.resume_token) save_resume_token(stream.resume_token)

View File

@@ -1,17 +1,19 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import Blueprint, jsonify, render_template from flask import Blueprint, jsonify, render_template, request
from .get_period_times import ( from .get_period_times import (
SUPPORTED_TIMEZONES,
convert_seconds, convert_seconds,
default_timezone,
get_current_task_info, get_current_task_info,
get_period_totals, get_period_totals,
get_raw_switches, get_raw_switches,
get_task_blocks_calendar, get_task_blocks_calendar,
get_task_time_seconds, get_task_time_seconds,
get_timezone,
get_work_period_totals, get_work_period_totals,
task_or_none, task_or_none,
timezone,
) )
dmbp = Blueprint("deskmeter", __name__, url_prefix="/", template_folder="templates") dmbp = Blueprint("deskmeter", __name__, url_prefix="/", template_folder="templates")
@@ -29,13 +31,14 @@ 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.
""" """
from flask import request
task = None task = None
grid = int(request.args.get("grid", 1)) # Grid hours: 1, 3, or 6 grid = int(request.args.get("grid", 1)) # Grid hours: 1, 3, or 6
if grid not in [1, 3, 6]: if grid not in [1, 3, 6]:
grid = 1 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
if not month: if not month:
@@ -44,14 +47,14 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
day = datetime.today().day day = datetime.today().day
base_date = datetime(year, month, day).replace( base_date = datetime(year, month, day).replace(
hour=0, minute=0, second=0, tzinfo=timezone 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( blocks = get_task_blocks_calendar(
start, end, task, min_block_seconds=60, grid_hours=grid 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)
@@ -61,7 +64,7 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
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( blocks = get_task_blocks_calendar(
start, end, task, min_block_seconds=300, grid_hours=grid 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)
@@ -70,20 +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( blocks = get_task_blocks_calendar(
start, end, task, min_block_seconds=600, grid_hours=grid 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:
@@ -94,7 +97,7 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
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( blocks = get_task_blocks_calendar(
start, end, task, min_block_seconds=60, grid_hours=grid 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)
@@ -111,6 +114,8 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
next_date=next_date, next_date=next_date,
days=days, days=days,
grid=grid, grid=grid,
tz_name=tz_name,
timezones=SUPPORTED_TIMEZONES,
auto_refresh=False, auto_refresh=False,
) )
@@ -124,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:
@@ -132,7 +140,7 @@ def switches_view(scope="daily", year=None, month=None, day=None):
day = datetime.today().day day = datetime.today().day
base_date = datetime(year, month, day).replace( base_date = datetime(year, month, day).replace(
hour=0, minute=0, second=0, tzinfo=timezone hour=0, minute=0, second=0, tzinfo=tz
) )
if scope == "daily": if scope == "daily":
@@ -150,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
@@ -168,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",
@@ -179,6 +187,8 @@ 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,
tz_name=tz_name,
timezones=SUPPORTED_TIMEZONES,
auto_refresh=False, auto_refresh=False,
) )
@@ -191,8 +201,12 @@ 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)
@@ -230,8 +244,12 @@ 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)
@@ -259,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)
@@ -274,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)
@@ -291,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)
@@ -308,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

@@ -6,9 +6,31 @@ from zoneinfo import ZoneInfo
from pymongo import MongoClient from pymongo import MongoClient
timezone = ZoneInfo("America/Argentina/Buenos_Aires") default_timezone = ZoneInfo("America/Argentina/Buenos_Aires")
timezone = default_timezone # Keep for backwards compatibility
utctz = ZoneInfo("UTC") utctz = ZoneInfo("UTC")
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")) client = MongoClient(os.environ.get("MONGODB_HOST", "localhost"))
db = client.deskmeter db = client.deskmeter
switches = db.switch switches = db.switch
@@ -209,7 +231,7 @@ def get_work_period_totals(start, end):
def get_task_blocks_calendar( def get_task_blocks_calendar(
start, end, task=None, min_block_seconds=300, grid_hours=1 start, end, task=None, min_block_seconds=300, grid_hours=1, tz=None
): ):
""" """
Get task blocks for calendar-style visualization, aggregated by time grid. Get task blocks for calendar-style visualization, aggregated by time grid.
@@ -231,6 +253,8 @@ def get_task_blocks_calendar(
'active_ratio': float (always 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 = { match_query = {
@@ -252,7 +276,7 @@ def get_task_blocks_calendar(
for switch in raw_switches: for switch in raw_switches:
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)
@@ -290,7 +314,7 @@ def get_task_blocks_calendar(
for (date, grid_hour, task_id), data in grid_task_time.items(): for (date, grid_hour, task_id), data in grid_task_time.items():
if data["duration"] >= min_block_seconds: if data["duration"] >= min_block_seconds:
grid_start = datetime( grid_start = datetime(
date.year, date.month, date.day, grid_hour, 0, 0, tzinfo=timezone date.year, date.month, date.day, grid_hour, 0, 0, tzinfo=local_tz
) )
blocks.append( blocks.append(
@@ -310,7 +334,7 @@ def get_task_blocks_calendar(
return sorted(blocks, key=lambda x: (x["start"], x["task_path"])) return sorted(blocks, key=lambda x: (x["start"], x["task_path"]))
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.
@@ -323,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}}
@@ -342,7 +368,7 @@ def get_raw_switches(start, end, task=None):
"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"],
} }
) )

View File

@@ -86,6 +86,7 @@
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: flex-end; justify-content: flex-end;
box-sizing: border-box;
} }
.days-grid { .days-grid {
@@ -114,6 +115,7 @@
font-weight: bold; font-weight: bold;
font-size: 11pt; font-size: 11pt;
color: #e0e0e0; color: #e0e0e0;
box-sizing: border-box;
} }
.day-grid { .day-grid {
@@ -137,17 +139,25 @@
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 2px 4px rgba(0, 0, 0, 0.5); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(0, 0, 0, 0.3); 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;
min-height: fit-content;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7); 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 {
font-weight: bold; font-weight: bold;
margin-bottom: 2px; margin-bottom: 2px;
@@ -157,28 +167,6 @@
font-size: 8pt; font-size: 8pt;
opacity: 0.9; opacity: 0.9;
} }
.block-tooltip {
display: none;
position: absolute;
background: #2a2a2a;
color: #e0e0e0;
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;
border: 1px solid #444;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.task-block:hover .block-tooltip {
display: block;
}
</style> </style>
{% endblock head %} {% block content %} {% endblock head %} {% block content %}
@@ -194,25 +182,45 @@
<a href="/">Today</a> <a href="/">Today</a>
<a href="/calendar/daily">Calendar</a> <a href="/calendar/daily">Calendar</a>
<a <a
href="/switches/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}" href="/switches/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
>Switches</a >Switches</a
> >
<a href="/totals">All Time</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>
<div class="nav-tabs"> <div class="nav-tabs">
<a <a
href="/calendar/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid={{ grid }}" href="/calendar/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid={{ grid }}{% if tz_name %}&tz={{ tz_name }}{% endif %}"
class="{% if scope == 'daily' %}active{% endif %}" class="{% if scope == 'daily' %}active{% endif %}"
>Daily</a >Daily</a
> >
<a <a
href="/calendar/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid={{ grid }}" href="/calendar/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid={{ grid }}{% if tz_name %}&tz={{ tz_name }}{% endif %}"
class="{% if scope == 'weekly' %}active{% endif %}" class="{% if scope == 'weekly' %}active{% endif %}"
>Weekly</a >Weekly</a
> >
<a <a
href="/calendar/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid={{ grid }}" 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 %}" class="{% if scope == 'monthly' %}active{% endif %}"
>Monthly</a >Monthly</a
> >
@@ -221,19 +229,19 @@
> >
<span style="color: #999; font-size: 11pt">Grid:</span> <span style="color: #999; font-size: 11pt">Grid:</span>
<a <a
href="/calendar/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid=1" 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 %}" class="{% if grid == 1 %}active{% endif %}"
style="font-size: 11pt" style="font-size: 11pt"
>1h</a >1h</a
> >
<a <a
href="/calendar/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid=3" 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 %}" class="{% if grid == 3 %}active{% endif %}"
style="font-size: 11pt" style="font-size: 11pt"
>3h</a >3h</a
> >
<a <a
href="/calendar/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid=6" 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 %}" class="{% if grid == 6 %}active{% endif %}"
style="font-size: 11pt" style="font-size: 11pt"
>6h</a >6h</a
@@ -243,7 +251,7 @@
<div class="date-nav"> <div class="date-nav">
<a <a
href="/calendar/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}?grid={{ grid }}" href="/calendar/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}?grid={{ grid }}{% if tz_name %}&tz={{ tz_name }}{% endif %}"
></a ></a
> >
{% if scope == 'daily' %} {% if scope == 'daily' %}
@@ -254,7 +262,7 @@
<span class="date-info">{{ base_date.strftime('%B %Y') }}</span> <span class="date-info">{{ base_date.strftime('%B %Y') }}</span>
{% endif %} {% endif %}
<a <a
href="/calendar/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}?grid={{ grid }}" href="/calendar/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}?grid={{ grid }}{% if tz_name %}&tz={{ tz_name }}{% endif %}"
></a ></a
> >
</div> </div>
@@ -284,41 +292,31 @@
></div> ></div>
{% endfor %} {% set day_blocks = [] %} {% for block in blocks %} {% endfor %} {% set day_blocks = [] %} {% for block in blocks %}
{% if block.start.date() == day.date() %} {% set _ = {% if block.start.date() == day.date() %} {% set _ =
day_blocks.append(block) %} {% endif %} {% endfor %} {# Group day_blocks.append(block) %} {% endif %} {% endfor %} {% set
blocks by hour for stacking #} {% set hour_groups = {} %} {% for hour_groups = {} %} {% for block in day_blocks %} {% set
block in day_blocks %} {% set hour_key = block.hour %} {% if hour_key = block.hour %} {% if hour_key not in hour_groups %} {%
hour_key not in hour_groups %} {% set _ = set _ = hour_groups.update({hour_key: []}) %} {% endif %} {% set
hour_groups.update({hour_key: []}) %} {% endif %} {% set _ = _ = hour_groups[hour_key].append(block) %} {% endfor %} {% for
hour_groups[hour_key].append(block) %} {% endfor %} {# Render hour_key, hour_blocks in hour_groups.items() %} {% for block in
blocks stacked within each hour #} {% for hour_key, hour_blocks hour_blocks %} {% set idx = loop.index0 %} {% set base_top_px =
in hour_groups.items() %} {% for block in hour_blocks %} {% set block.hour * 60 %} {% set duration_hours = block.duration /
idx = loop.index0 %} {% set base_top_px = block.hour * 60 %} {% 3600.0 %} {% set height_px = duration_hours * 60 %} {% if
set duration_hours = block.duration / 3600.0 %} {% set height_px height_px < 2 %}{% set height_px = 2 %}{% endif %} {% set
= duration_hours * 60 %} {% if height_px < 2 %}{% set height_px task_hash = block.task_path|hash if block.task_path else 0 %} {%
= 2 %}{% endif %} {% set task_hash = block.task_path|hash if set base_color_hue = task_hash % 360 %} {% set active_color =
block.task_path else 0 %} {% set base_color_hue = task_hash % 'hsl(%d, 60%%, 45%%)'|format(base_color_hue) %} {% set
360 %} {% set active_color = 'hsl(%d, 60%%, offset_pct = idx * 8 %} {% set width_pct = 100 - (idx * 8) - 2
45%%)'|format(base_color_hue) %} {# Cascade effect: shift right %} {% set vertical_offset_px = idx * 4 %} {% set top_px =
and down for overlapping blocks #} {# Each block shifts 8% right base_top_px + vertical_offset_px %}
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 <div
class="task-block" class="task-block"
style="top: {{ top_px }}px; height: {{ height_px }}px; 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 }};"
left: {{ offset_pct }}%; width: {{ width_pct }}%;
background: {{ active_color }}; opacity: 0.9;"
> >
<div class="task-label">{{ block.task_path }}</div> <div class="task-label">{{ block.task_path }}</div>
<div class="task-time"> <div class="task-time">
{{ (block.duration // 60)|int }}m {{ (block.duration // 60)|int }}m
</div> </div>
<div class="block-tooltip">
<strong>{{ block.task_path }}</strong><br />
Hour: {{ '%02d:00'|format(block.hour) }}<br />
Duration: {{ (block.duration // 60)|int }} minutes
</div>
</div> </div>
{% endfor %} {% endfor %} {% endfor %} {% endfor %}
</div> </div>

View File

@@ -148,24 +148,29 @@
" "
> >
<a href="/">Today</a> <a href="/">Today</a>
<a href="/calendar/daily">Calendar</a> <a href="/calendar/daily{% if tz_name %}?tz={{ tz_name }}{% endif %}"
>Calendar</a
>
<a href="/switches/daily">Switches</a> <a href="/switches/daily">Switches</a>
<a href="/totals">All Time</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>
<div class="nav-tabs"> <div class="nav-tabs">
<a <a
href="/switches/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}" href="/switches/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
class="{% if scope == 'daily' %}active{% endif %}" class="{% if scope == 'daily' %}active{% endif %}"
>Daily</a >Daily</a
> >
<a <a
href="/switches/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}" href="/switches/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
class="{% if scope == 'weekly' %}active{% endif %}" class="{% if scope == 'weekly' %}active{% endif %}"
>Weekly</a >Weekly</a
> >
<a <a
href="/switches/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}" 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 %}" class="{% if scope == 'monthly' %}active{% endif %}"
>Monthly</a >Monthly</a
> >
@@ -173,7 +178,7 @@
<div class="date-nav"> <div class="date-nav">
<a <a
href="/switches/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}" href="/switches/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
></a ></a
> >
{% if scope == 'daily' %} {% if scope == 'daily' %}
@@ -184,7 +189,7 @@
<span class="date-info">{{ base_date.strftime('%B %Y') }}</span> <span class="date-info">{{ base_date.strftime('%B %Y') }}</span>
{% endif %} {% endif %}
<a <a
href="/switches/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}" href="/switches/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
></a ></a
> >
</div> </div>