feat: unified calendar view and eisenhower matrix view
This commit is contained in:
@@ -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():
|
||||||
|
|||||||
4
main.py
4
main.py
@@ -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)
|
||||||
|
|||||||
@@ -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
138
routers/calendar.py
Normal 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
189
routers/time_budgets.py
Normal 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)
|
||||||
182
static/style.css
182
static/style.css
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
58
templates/calendar.html
Normal 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">← 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 →</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 %}
|
||||||
76
templates/time_budgets.html
Normal file
76
templates/time_budgets.html
Normal 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">⏱</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 %}
|
||||||
47
templates/time_budgets_form.html
Normal file
47
templates/time_budgets_form.html
Normal 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 %}
|
||||||
@@ -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"],))
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user