feat: unified calendar view and eisenhower matrix view

This commit is contained in:
2026-03-01 22:17:23 +00:00
parent c21cbf5e9b
commit d792f89fe6
12 changed files with 715 additions and 1 deletions

View File

@@ -160,6 +160,7 @@ class BaseRepository:
"priority", "recurrence", "mime_type", "priority", "recurrence", "mime_type",
"category", "instructions", "expected_output", "estimated_days", "category", "instructions", "expected_output", "estimated_days",
"contact_id", "started_at", "contact_id", "started_at",
"weekly_hours", "effective_from",
} }
clean_data = {} clean_data = {}
for k, v in data.items(): for k, v in data.items():

View File

@@ -40,6 +40,8 @@ from routers import (
appointments as appointments_router, appointments as appointments_router,
time_tracking as time_tracking_router, time_tracking as time_tracking_router,
processes as processes_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(appointments_router.router)
app.include_router(time_tracking_router.router) app.include_router(time_tracking_router.router)
app.include_router(processes_router.router) app.include_router(processes_router.router)
app.include_router(calendar_router.router)
app.include_router(time_budgets_router.router)

View File

@@ -34,6 +34,7 @@ TRASH_ENTITIES = [
{"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"}, {"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"},
{"table": "processes", "label": "Processes", "name_col": "name", "url": "/processes/{id}"}, {"table": "processes", "label": "Processes", "name_col": "name", "url": "/processes/{id}"},
{"table": "process_runs", "label": "Process Runs", "name_col": "title", "url": "/processes/runs/{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"},
] ]

138
routers/calendar.py Normal file
View File

@@ -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",
})

189
routers/time_budgets.py Normal file
View File

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

View File

@@ -812,6 +812,19 @@ a:hover { color: var(--accent-hover); }
.focus-title { flex: 1; font-weight: 500; } .focus-title { flex: 1; font-weight: 500; }
.focus-meta { font-size: 0.78rem; color: var(--muted); } .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 ---- */ /* ---- Utility ---- */
.text-muted { color: var(--muted); } .text-muted { color: var(--muted); }
.text-sm { font-size: 0.82rem; } .text-sm { font-size: 0.82rem; }
@@ -1255,3 +1268,172 @@ a:hover { color: var(--accent-hover); }
.timer-detail-stop:hover { .timer-detail-stop:hover {
background: var(--red, #ef4444)33; 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; }
}

View File

@@ -60,6 +60,10 @@
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
Appointments Appointments
</a> </a>
<a href="/calendar" class="nav-item {{ 'active' if active_nav == 'calendar' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><rect x="7" y="14" width="3" height="3" rx="0.5"/><rect x="14" y="14" width="3" height="3" rx="0.5"/></svg>
Calendar
</a>
<a href="/decisions" class="nav-item {{ 'active' if active_nav == 'decisions' }}"> <a href="/decisions" class="nav-item {{ 'active' if active_nav == 'decisions' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M9 12l2 2 4-4"/></svg> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M9 12l2 2 4-4"/></svg>
Decisions Decisions
@@ -76,6 +80,10 @@
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Time Log Time Log
</a> </a>
<a href="/time-budgets" class="nav-item {{ 'active' if active_nav == 'time_budgets' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/><path d="M16 3l2 2-2 2"/></svg>
Time Budgets
</a>
<a href="/capture" class="nav-item {{ 'active' if active_nav == 'capture' }}"> <a href="/capture" class="nav-item {{ 'active' if active_nav == 'capture' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
Capture Capture
@@ -183,7 +191,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<span>Tasks</span> <span>Tasks</span>
</a> </a>
<a href="/appointments" class="mobile-nav-item {% if active_nav == 'appointments' %}active{% endif %}"> <a href="/calendar" class="mobile-nav-item {% if active_nav == 'calendar' %}active{% endif %}">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span>Calendar</span> <span>Calendar</span>
</a> </a>

58
templates/calendar.html Normal file
View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<div>
<h1 class="page-title">Calendar</h1>
</div>
</div>
<!-- Month Navigation -->
<div class="cal-nav">
<a href="/calendar?year={{ prev_year }}&month={{ prev_month }}" class="btn btn-secondary btn-sm">&larr; Prev</a>
<span class="cal-month-label">{{ month_name }} {{ year }}</span>
<a href="/calendar?year={{ next_year }}&month={{ next_month }}" class="btn btn-secondary btn-sm">Next &rarr;</a>
{% if year != today.year or month != today.month %}
<a href="/calendar" class="btn btn-ghost btn-sm">Today</a>
{% endif %}
</div>
<!-- Legend -->
<div class="cal-legend">
<span class="cal-legend-item"><span class="cal-dot cal-dot-appointment"></span> Appointment</span>
<span class="cal-legend-item"><span class="cal-dot cal-dot-meeting"></span> Meeting</span>
<span class="cal-legend-item"><span class="cal-dot cal-dot-task"></span> Task</span>
</div>
<!-- Calendar Grid -->
<div class="cal-grid">
<div class="cal-header-row">
<div class="cal-header-cell">Sun</div>
<div class="cal-header-cell">Mon</div>
<div class="cal-header-cell">Tue</div>
<div class="cal-header-cell">Wed</div>
<div class="cal-header-cell">Thu</div>
<div class="cal-header-cell">Fri</div>
<div class="cal-header-cell">Sat</div>
</div>
{% for week in weeks %}
<div class="cal-week-row">
{% for day in week %}
<div class="cal-day-cell {{ 'cal-day-empty' if day == 0 }} {{ 'cal-day-today' if day > 0 and today.year == year and today.month == month and today.day == day }}">
{% if day > 0 %}
<div class="cal-day-num">{{ day }}</div>
<div class="cal-events">
{% for event in days_map.get(day, []) %}
<a href="{{ event.url }}" class="cal-event cal-event-{{ event.type }}" title="{{ event.title }}">
{% if event.time %}<span class="cal-event-time">{{ event.time }}</span>{% endif %}
<span class="cal-event-title">{{ event.title }}</span>
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<div>
<h1 class="page-title">Time Budgets <span class="page-count">({{ count }})</span></h1>
</div>
<a href="/time-budgets/create" class="btn btn-primary">+ New Budget</a>
</div>
{% if overcommitted %}
<div class="alert alert-warning mb-3">
<strong>Overcommitted!</strong> Your budgets total {{ "%.1f"|format(total_budgeted) }} hours/week, which exceeds the 168 hours available.
</div>
{% endif %}
{% if current_budgets %}
<div class="card mb-4">
<div class="card-header">
<span class="card-title">This Week's Budget vs Actual</span>
<span class="text-muted text-sm">{{ "%.1f"|format(total_budgeted) }}h budgeted total</span>
</div>
{% for b in current_budgets %}
<div class="list-row" style="flex-wrap: wrap; gap: 8px;">
<span class="domain-dot" style="background: {{ b.domain_color or '#4F6EF7' }}; flex-shrink: 0;"></span>
<div class="row-title" style="min-width: 120px;">
{{ b.domain_name }}
</div>
<div style="flex: 2; min-width: 200px; display: flex; align-items: center; gap: 8px;">
<div class="progress-bar" style="flex: 1; height: 8px;">
<div class="progress-fill" style="width: {{ [b.pct, 100] | min }}%; {{ 'background: var(--red);' if b.pct > 100 }}"></div>
</div>
</div>
<span class="row-meta" style="min-width: 100px; text-align: right;">
<strong>{{ b.actual_hours }}h</strong> / {{ b.weekly_hours_float }}h
</span>
<span class="row-meta" style="min-width: 40px; text-align: right; {{ 'color: var(--red); font-weight: 600;' if b.pct > 100 else ('color: var(--green);' if b.pct >= 80 else '') }}">
{{ b.pct }}%
</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if all_budgets %}
<div class="card">
<div class="card-header">
<span class="card-title">All Budgets</span>
</div>
{% for b in all_budgets %}
<div class="list-row">
<span class="domain-dot" style="background: {{ b.domain_color or '#4F6EF7' }}; flex-shrink: 0;"></span>
<div class="row-title">
{{ b.domain_name }}
</div>
<span class="row-meta">{{ b.weekly_hours }}h / week</span>
<span class="row-meta">from {{ b.effective_from.strftime('%b %-d, %Y') if b.effective_from else '—' }}</span>
<div class="row-actions">
<a href="/time-budgets/{{ b.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form method="POST" action="/time-budgets/{{ b.id }}/delete" data-confirm="Delete this budget?">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red);">Delete</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">&#9201;</div>
<div class="empty-state-text">No time budgets defined</div>
<a href="/time-budgets/create" class="btn btn-primary">Create a Budget</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<div>
<h1 class="page-title">{{ 'Edit Time Budget' if budget else 'New Time Budget' }}</h1>
</div>
</div>
<div class="card" style="max-width: 600px;">
<form method="POST" action="{{ '/time-budgets/' ~ budget.id ~ '/edit' if budget else '/time-budgets/create' }}">
<div class="form-grid" style="grid-template-columns: 1fr;">
<div class="form-group">
<label class="form-label">Domain *</label>
<select name="domain_id" class="form-select" required>
<option value="">Select domain...</option>
{% for d in domains %}
<option value="{{ d.id }}" {{ 'selected' if budget and budget.domain_id|string == d.id|string }}>
{{ d.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Weekly Hours *</label>
<input type="number" name="weekly_hours" class="form-input"
value="{{ budget.weekly_hours if budget else '' }}"
min="0" max="168" step="0.5" required
placeholder="e.g. 10">
</div>
<div class="form-group">
<label class="form-label">Effective From *</label>
<input type="date" name="effective_from" class="form-input"
value="{{ budget.effective_from.strftime('%Y-%m-%d') if budget and budget.effective_from else '' }}"
required>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if budget else 'Create Budget' }}</button>
<a href="/time-budgets" class="btn btn-secondary">Cancel</a>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@@ -32,6 +32,7 @@ SEED_IDS = {
"process": "a0000000-0000-0000-0000-000000000010", "process": "a0000000-0000-0000-0000-000000000010",
"process_step": "a0000000-0000-0000-0000-000000000011", "process_step": "a0000000-0000-0000-0000-000000000011",
"process_run": "a0000000-0000-0000-0000-000000000012", "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 ON CONFLICT (id) DO NOTHING
""", (d["process_run"], d["process"])) """, (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() sync_conn.commit()
except Exception as e: except Exception as e:
sync_conn.rollback() sync_conn.rollback()
@@ -229,6 +237,7 @@ def all_seeds(sync_conn):
# Cleanup: delete all seed data (reverse dependency order) # Cleanup: delete all seed data (reverse dependency order)
try: 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_runs WHERE id = %s", (d["process_run"],))
cur.execute("DELETE FROM process_steps WHERE id = %s", (d["process_step"],)) cur.execute("DELETE FROM process_steps WHERE id = %s", (d["process_step"],))
cur.execute("DELETE FROM processes WHERE id = %s", (d["process"],)) cur.execute("DELETE FROM processes WHERE id = %s", (d["process"],))

View File

@@ -43,6 +43,7 @@ PREFIX_TO_SEED = {
"/time": "task", "/time": "task",
"/processes": "process", "/processes": "process",
"/processes/runs": "process_run", "/processes/runs": "process_run",
"/time-budgets": "time_budget",
"/files": None, "/files": None,
"/admin/trash": None, "/admin/trash": None,
} }