From d792f89fe6a9b372ae72942ea01379a9d94be4aa Mon Sep 17 00:00:00 2001 From: M Dombaugh Date: Sun, 1 Mar 2026 22:17:23 +0000 Subject: [PATCH] feat: unified calendar view and eisenhower matrix view --- core/base_repository.py | 1 + main.py | 4 + routers/admin.py | 1 + routers/calendar.py | 138 ++++++++++++++++++++++ routers/time_budgets.py | 189 +++++++++++++++++++++++++++++++ static/style.css | 182 +++++++++++++++++++++++++++++ templates/base.html | 10 +- templates/calendar.html | 58 ++++++++++ templates/time_budgets.html | 76 +++++++++++++ templates/time_budgets_form.html | 47 ++++++++ tests/conftest.py | 9 ++ tests/registry.py | 1 + 12 files changed, 715 insertions(+), 1 deletion(-) create mode 100644 routers/calendar.py create mode 100644 routers/time_budgets.py create mode 100644 templates/calendar.html create mode 100644 templates/time_budgets.html create mode 100644 templates/time_budgets_form.html diff --git a/core/base_repository.py b/core/base_repository.py index 38447d2..ebc0843 100644 --- a/core/base_repository.py +++ b/core/base_repository.py @@ -160,6 +160,7 @@ class BaseRepository: "priority", "recurrence", "mime_type", "category", "instructions", "expected_output", "estimated_days", "contact_id", "started_at", + "weekly_hours", "effective_from", } clean_data = {} for k, v in data.items(): diff --git a/main.py b/main.py index 87d06e2..50e4b17 100644 --- a/main.py +++ b/main.py @@ -40,6 +40,8 @@ from routers import ( appointments as appointments_router, time_tracking as time_tracking_router, processes as processes_router, + calendar as calendar_router, + time_budgets as time_budgets_router, ) @@ -195,3 +197,5 @@ app.include_router(weblinks_router.router) app.include_router(appointments_router.router) app.include_router(time_tracking_router.router) app.include_router(processes_router.router) +app.include_router(calendar_router.router) +app.include_router(time_budgets_router.router) diff --git a/routers/admin.py b/routers/admin.py index c976295..08d90b8 100644 --- a/routers/admin.py +++ b/routers/admin.py @@ -34,6 +34,7 @@ TRASH_ENTITIES = [ {"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"}, {"table": "processes", "label": "Processes", "name_col": "name", "url": "/processes/{id}"}, {"table": "process_runs", "label": "Process Runs", "name_col": "title", "url": "/processes/runs/{id}"}, + {"table": "time_budgets", "label": "Time Budgets", "name_col": "id", "url": "/time-budgets"}, ] diff --git a/routers/calendar.py b/routers/calendar.py new file mode 100644 index 0000000..dd6df0a --- /dev/null +++ b/routers/calendar.py @@ -0,0 +1,138 @@ +"""Calendar: unified read-only month view of appointments, meetings, and tasks.""" + +from fastapi import APIRouter, Request, Depends +from fastapi.templating import Jinja2Templates +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text +from datetime import date, timedelta +import calendar + +from core.database import get_db +from core.sidebar import get_sidebar_data + +router = APIRouter(prefix="/calendar", tags=["calendar"]) +templates = Jinja2Templates(directory="templates") + + +@router.get("/") +async def calendar_view( + request: Request, + year: int = None, + month: int = None, + db: AsyncSession = Depends(get_db), +): + today = date.today() + year = year or today.year + month = month or today.month + + # Clamp to valid range + if month < 1 or month > 12: + month = today.month + if year < 2000 or year > 2100: + year = today.year + + first_day = date(year, month, 1) + last_day = date(year, month, calendar.monthrange(year, month)[1]) + + # Prev/next month + prev_month = first_day - timedelta(days=1) + next_month = last_day + timedelta(days=1) + + sidebar = await get_sidebar_data(db) + + # Appointments in this month (by start_at) + appt_result = await db.execute(text(""" + SELECT id, title, start_at, end_at, all_day, location + FROM appointments + WHERE is_deleted = false + AND start_at::date >= :first AND start_at::date <= :last + ORDER BY start_at + """), {"first": first_day, "last": last_day}) + appointments = [dict(r._mapping) for r in appt_result] + + # Meetings in this month (by meeting_date) + meet_result = await db.execute(text(""" + SELECT id, title, meeting_date, start_at, location, status + FROM meetings + WHERE is_deleted = false + AND meeting_date >= :first AND meeting_date <= :last + ORDER BY meeting_date, start_at + """), {"first": first_day, "last": last_day}) + meetings = [dict(r._mapping) for r in meet_result] + + # Tasks with due dates in this month (open/in_progress only) + task_result = await db.execute(text(""" + SELECT t.id, t.title, t.due_date, t.priority, t.status, + p.name as project_name + FROM tasks t + LEFT JOIN projects p ON t.project_id = p.id + WHERE t.is_deleted = false + AND t.status IN ('open', 'in_progress') + AND t.due_date >= :first AND t.due_date <= :last + ORDER BY t.due_date, t.priority + """), {"first": first_day, "last": last_day}) + tasks = [dict(r._mapping) for r in task_result] + + # Build day-indexed event map + days_map = {} + for a in appointments: + day = a["start_at"].date().day if a["start_at"] else None + if day: + days_map.setdefault(day, []).append({ + "type": "appointment", + "id": a["id"], + "title": a["title"], + "time": None if a["all_day"] else a["start_at"].strftime("%-I:%M %p"), + "url": f"/appointments/{a['id']}", + "all_day": a["all_day"], + }) + + for m in meetings: + day = m["meeting_date"].day if m["meeting_date"] else None + if day: + time_str = m["start_at"].strftime("%-I:%M %p") if m["start_at"] else None + days_map.setdefault(day, []).append({ + "type": "meeting", + "id": m["id"], + "title": m["title"], + "time": time_str, + "url": f"/meetings/{m['id']}", + "all_day": False, + }) + + for t in tasks: + day = t["due_date"].day if t["due_date"] else None + if day: + days_map.setdefault(day, []).append({ + "type": "task", + "id": t["id"], + "title": t["title"], + "time": None, + "url": f"/tasks/{t['id']}", + "priority": t["priority"], + "project_name": t.get("project_name"), + "all_day": False, + }) + + # Build calendar grid (weeks of days) + # Monday=0, Sunday=6 + cal = calendar.Calendar(firstweekday=6) # Sunday start + weeks = cal.monthdayscalendar(year, month) + + return templates.TemplateResponse("calendar.html", { + "request": request, + "sidebar": sidebar, + "year": year, + "month": month, + "month_name": calendar.month_name[month], + "weeks": weeks, + "days_map": days_map, + "today": today, + "first_day": first_day, + "prev_year": prev_month.year, + "prev_month": prev_month.month, + "next_year": next_month.year, + "next_month": next_month.month, + "page_title": f"Calendar - {calendar.month_name[month]} {year}", + "active_nav": "calendar", + }) diff --git a/routers/time_budgets.py b/routers/time_budgets.py new file mode 100644 index 0000000..35a9855 --- /dev/null +++ b/routers/time_budgets.py @@ -0,0 +1,189 @@ +"""Time Budgets: weekly hour allocations per domain with actual vs budgeted comparison.""" + +from fastapi import APIRouter, Request, Depends, Form +from fastapi.templating import Jinja2Templates +from fastapi.responses import RedirectResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text +from typing import Optional + +from core.database import get_db +from core.base_repository import BaseRepository +from core.sidebar import get_sidebar_data + +router = APIRouter(prefix="/time-budgets", tags=["time_budgets"]) +templates = Jinja2Templates(directory="templates") + + +@router.get("/") +async def list_time_budgets( + request: Request, + db: AsyncSession = Depends(get_db), +): + sidebar = await get_sidebar_data(db) + + # Get current budget per domain (most recent effective_from <= today) + result = await db.execute(text(""" + SELECT DISTINCT ON (tb.domain_id) + tb.*, d.name as domain_name, d.color as domain_color + FROM time_budgets tb + JOIN domains d ON tb.domain_id = d.id + WHERE tb.is_deleted = false AND d.is_deleted = false + AND tb.effective_from <= CURRENT_DATE + ORDER BY tb.domain_id, tb.effective_from DESC + """)) + current_budgets = [dict(r._mapping) for r in result] + + # Get actual hours per domain this week (Mon-Sun) + result = await db.execute(text(""" + SELECT t.domain_id, + COALESCE(SUM( + CASE + WHEN te.duration_minutes IS NOT NULL THEN te.duration_minutes + WHEN te.end_at IS NOT NULL THEN EXTRACT(EPOCH FROM (te.end_at - te.start_at)) / 60 + ELSE 0 + END + ), 0) / 60.0 as actual_hours + FROM time_entries te + JOIN tasks t ON te.task_id = t.id + WHERE te.is_deleted = false + AND te.start_at >= date_trunc('week', CURRENT_DATE) + AND te.start_at < date_trunc('week', CURRENT_DATE) + INTERVAL '7 days' + AND t.domain_id IS NOT NULL + GROUP BY t.domain_id + """)) + actual_map = {str(r._mapping["domain_id"]): float(r._mapping["actual_hours"]) for r in result} + + # Attach actual hours to budgets + for b in current_budgets: + b["actual_hours"] = round(actual_map.get(str(b["domain_id"]), 0), 1) + b["weekly_hours_float"] = float(b["weekly_hours"]) + if b["weekly_hours_float"] > 0: + b["pct"] = round(b["actual_hours"] / b["weekly_hours_float"] * 100) + else: + b["pct"] = 0 + + total_budgeted = sum(float(b["weekly_hours"]) for b in current_budgets) + overcommitted = total_budgeted > 168 + + # Also get all budgets (including future / historical) for full list + result = await db.execute(text(""" + SELECT tb.*, d.name as domain_name, d.color as domain_color + FROM time_budgets tb + JOIN domains d ON tb.domain_id = d.id + WHERE tb.is_deleted = false AND d.is_deleted = false + ORDER BY tb.effective_from DESC, d.name + """)) + all_budgets = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("time_budgets.html", { + "request": request, + "sidebar": sidebar, + "current_budgets": current_budgets, + "all_budgets": all_budgets, + "total_budgeted": total_budgeted, + "overcommitted": overcommitted, + "count": len(all_budgets), + "page_title": "Time Budgets", + "active_nav": "time_budgets", + }) + + +@router.get("/create") +async def create_form( + request: Request, + db: AsyncSession = Depends(get_db), +): + sidebar = await get_sidebar_data(db) + + result = await db.execute(text(""" + SELECT id, name, color FROM domains + WHERE is_deleted = false ORDER BY sort_order, name + """)) + domains = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("time_budgets_form.html", { + "request": request, + "sidebar": sidebar, + "budget": None, + "domains": domains, + "page_title": "New Time Budget", + "active_nav": "time_budgets", + }) + + +@router.post("/create") +async def create_budget( + request: Request, + domain_id: str = Form(...), + weekly_hours: str = Form(...), + effective_from: str = Form(...), + db: AsyncSession = Depends(get_db), +): + repo = BaseRepository("time_budgets", db) + data = { + "domain_id": domain_id, + "weekly_hours": float(weekly_hours), + "effective_from": effective_from, + } + budget = await repo.create(data) + return RedirectResponse(url="/time-budgets", status_code=303) + + +@router.get("/{budget_id}/edit") +async def edit_form( + request: Request, + budget_id: str, + db: AsyncSession = Depends(get_db), +): + sidebar = await get_sidebar_data(db) + repo = BaseRepository("time_budgets", db) + budget = await repo.get(budget_id) + + if not budget: + return RedirectResponse(url="/time-budgets", status_code=303) + + result = await db.execute(text(""" + SELECT id, name, color FROM domains + WHERE is_deleted = false ORDER BY sort_order, name + """)) + domains = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("time_budgets_form.html", { + "request": request, + "sidebar": sidebar, + "budget": budget, + "domains": domains, + "page_title": "Edit Time Budget", + "active_nav": "time_budgets", + }) + + +@router.post("/{budget_id}/edit") +async def update_budget( + request: Request, + budget_id: str, + domain_id: str = Form(...), + weekly_hours: str = Form(...), + effective_from: str = Form(...), + db: AsyncSession = Depends(get_db), +): + repo = BaseRepository("time_budgets", db) + data = { + "domain_id": domain_id, + "weekly_hours": float(weekly_hours), + "effective_from": effective_from, + } + await repo.update(budget_id, data) + return RedirectResponse(url="/time-budgets", status_code=303) + + +@router.post("/{budget_id}/delete") +async def delete_budget( + request: Request, + budget_id: str, + db: AsyncSession = Depends(get_db), +): + repo = BaseRepository("time_budgets", db) + await repo.soft_delete(budget_id) + return RedirectResponse(url="/time-budgets", status_code=303) diff --git a/static/style.css b/static/style.css index bee6e3c..62fc5bc 100644 --- a/static/style.css +++ b/static/style.css @@ -812,6 +812,19 @@ a:hover { color: var(--accent-hover); } .focus-title { flex: 1; font-weight: 500; } .focus-meta { font-size: 0.78rem; color: var(--muted); } +/* ---- Alerts ---- */ +.alert { + padding: 12px 16px; + border-radius: var(--radius); + font-size: 0.88rem; + font-weight: 500; +} +.alert-warning { + background: var(--amber-soft); + color: var(--amber); + border: 1px solid var(--amber); +} + /* ---- Utility ---- */ .text-muted { color: var(--muted); } .text-sm { font-size: 0.82rem; } @@ -1255,3 +1268,172 @@ a:hover { color: var(--accent-hover); } .timer-detail-stop:hover { background: var(--red, #ef4444)33; } + +/* ---- Calendar View ---- */ +.cal-nav { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.cal-month-label { + font-size: 1.15rem; + font-weight: 700; + min-width: 180px; + text-align: center; +} + +.cal-legend { + display: flex; + gap: 16px; + margin-bottom: 12px; + font-size: 0.78rem; + color: var(--muted); +} + +.cal-legend-item { + display: flex; + align-items: center; + gap: 4px; +} + +.cal-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.cal-dot-appointment { background: var(--amber); } +.cal-dot-meeting { background: var(--purple); } +.cal-dot-task { background: var(--accent); } + +.cal-grid { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.cal-header-row { + display: grid; + grid-template-columns: repeat(7, 1fr); + border-bottom: 1px solid var(--border); +} + +.cal-header-cell { + padding: 8px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + text-align: center; +} + +.cal-week-row { + display: grid; + grid-template-columns: repeat(7, 1fr); + min-height: 100px; +} + +.cal-week-row + .cal-week-row { + border-top: 1px solid var(--border); +} + +.cal-day-cell { + padding: 4px; + border-right: 1px solid var(--border); + min-height: 100px; + display: flex; + flex-direction: column; +} + +.cal-day-cell:last-child { + border-right: none; +} + +.cal-day-empty { + background: var(--surface2); + min-height: 100px; +} + +.cal-day-today { + background: var(--accent-soft); +} + +.cal-day-num { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-secondary); + padding: 2px 4px; + text-align: right; +} + +.cal-day-today .cal-day-num { + color: var(--accent); + font-weight: 700; +} + +.cal-events { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden; +} + +.cal-event { + display: block; + padding: 2px 4px; + border-radius: var(--radius-sm); + font-size: 0.72rem; + line-height: 1.3; + text-decoration: none; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + transition: opacity var(--transition); +} + +.cal-event:hover { + opacity: 0.8; +} + +.cal-event-appointment { + background: var(--amber-soft); + color: var(--amber); + border-left: 2px solid var(--amber); +} + +.cal-event-meeting { + background: var(--purple-soft); + color: var(--purple); + border-left: 2px solid var(--purple); +} + +.cal-event-task { + background: var(--accent-soft); + color: var(--accent); + border-left: 2px solid var(--accent); +} + +.cal-event-time { + font-weight: 600; + margin-right: 2px; +} + +.cal-event-title { + font-weight: 500; +} + +@media (max-width: 768px) { + .cal-week-row { min-height: 60px; } + .cal-day-cell { min-height: 60px; padding: 2px; } + .cal-day-empty { min-height: 60px; } + .cal-day-num { font-size: 0.72rem; } + .cal-event { font-size: 0.65rem; padding: 1px 2px; } + .cal-event-time { display: none; } + .cal-month-label { font-size: 1rem; min-width: 140px; } +} diff --git a/templates/base.html b/templates/base.html index 837066e..ff1e648 100644 --- a/templates/base.html +++ b/templates/base.html @@ -60,6 +60,10 @@ Appointments + + + Calendar + Decisions @@ -76,6 +80,10 @@ Time Log + + + Time Budgets + Capture @@ -183,7 +191,7 @@ Tasks - + Calendar diff --git a/templates/calendar.html b/templates/calendar.html new file mode 100644 index 0000000..99f06bd --- /dev/null +++ b/templates/calendar.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block content %} + + + +
+ ← Prev + {{ month_name }} {{ year }} + Next → + {% if year != today.year or month != today.month %} + Today + {% endif %} +
+ + +
+ Appointment + Meeting + Task +
+ + +
+
+
Sun
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+
+ {% for week in weeks %} +
+ {% for day in week %} +
+ {% if day > 0 %} +
{{ day }}
+
+ {% for event in days_map.get(day, []) %} + + {% if event.time %}{{ event.time }}{% endif %} + {{ event.title }} + + {% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+ {% endfor %} +
+{% endblock %} diff --git a/templates/time_budgets.html b/templates/time_budgets.html new file mode 100644 index 0000000..2b1681a --- /dev/null +++ b/templates/time_budgets.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} + +{% block content %} + + +{% if overcommitted %} +
+ Overcommitted! Your budgets total {{ "%.1f"|format(total_budgeted) }} hours/week, which exceeds the 168 hours available. +
+{% endif %} + +{% if current_budgets %} +
+
+ This Week's Budget vs Actual + {{ "%.1f"|format(total_budgeted) }}h budgeted total +
+ + {% for b in current_budgets %} +
+ +
+ {{ b.domain_name }} +
+
+
+
+
+
+ + {{ b.actual_hours }}h / {{ b.weekly_hours_float }}h + + + {{ b.pct }}% + +
+ {% endfor %} +
+{% endif %} + +{% if all_budgets %} +
+
+ All Budgets +
+ + {% for b in all_budgets %} +
+ +
+ {{ b.domain_name }} +
+ {{ b.weekly_hours }}h / week + from {{ b.effective_from.strftime('%b %-d, %Y') if b.effective_from else '—' }} +
+ Edit +
+ +
+
+
+ {% endfor %} +
+{% else %} +
+
+
No time budgets defined
+ Create a Budget +
+{% endif %} +{% endblock %} diff --git a/templates/time_budgets_form.html b/templates/time_budgets_form.html new file mode 100644 index 0000000..dd59c50 --- /dev/null +++ b/templates/time_budgets_form.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block content %} + + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Cancel +
+
+
+
+{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py index 6df51be..1b7eeea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,6 +32,7 @@ SEED_IDS = { "process": "a0000000-0000-0000-0000-000000000010", "process_step": "a0000000-0000-0000-0000-000000000011", "process_run": "a0000000-0000-0000-0000-000000000012", + "time_budget": "a0000000-0000-0000-0000-000000000013", } @@ -220,6 +221,13 @@ def all_seeds(sync_conn): ON CONFLICT (id) DO NOTHING """, (d["process_run"], d["process"])) + # Time budget + cur.execute(""" + INSERT INTO time_budgets (id, domain_id, weekly_hours, effective_from, is_deleted, created_at, updated_at) + VALUES (%s, %s, 10, CURRENT_DATE, false, now(), now()) + ON CONFLICT (id) DO NOTHING + """, (d["time_budget"], d["domain"])) + sync_conn.commit() except Exception as e: sync_conn.rollback() @@ -229,6 +237,7 @@ def all_seeds(sync_conn): # Cleanup: delete all seed data (reverse dependency order) try: + cur.execute("DELETE FROM time_budgets WHERE id = %s", (d["time_budget"],)) cur.execute("DELETE FROM process_runs WHERE id = %s", (d["process_run"],)) cur.execute("DELETE FROM process_steps WHERE id = %s", (d["process_step"],)) cur.execute("DELETE FROM processes WHERE id = %s", (d["process"],)) diff --git a/tests/registry.py b/tests/registry.py index db1b831..4e7de7b 100644 --- a/tests/registry.py +++ b/tests/registry.py @@ -43,6 +43,7 @@ PREFIX_TO_SEED = { "/time": "task", "/processes": "process", "/processes/runs": "process_run", + "/time-budgets": "time_budget", "/files": None, "/admin/trash": None, }