📄 router.py 57,845 bytes Apr 30, 2026 📋 Raw

"""Family dashboard router - serves the dashboard HTML and API endpoints.

Per CONTRACT.md: All HTMX fragments moved to /ui/* namespace in ui_router.py.
This file keeps JSON API endpoints and HTML page serving.
"""

from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from datetime import datetime, timedelta
from pathlib import Path
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
import json

from fastapi.templating import Jinja2Templates

from shared.session_auth import require_auth, get_session, is_authenticated

router = APIRouter(prefix="", tags=["dashboard"])

Local templates for hoffdesk-api dashboard

DASHBOARD_TEMPLATES_DIR = Path("/home/hoffmann_admin/.openclaw/workspace-socrates/hoffdesk-api/dashboard/templates")
templates = Jinja2Templates(directory=str(DASHBOARD_TEMPLATES_DIR))

Path to test events file

TEST_EVENTS_FILE = Path("/home/hoffmann_admin/.openclaw/workspace-socrates/hoffdesk-api/data/test_events.json")

Pydantic models for request bodies

class RescheduleRequest(BaseModel):
new_date: str
new_time: Optional[str] = None

class CounterRequest(BaseModel):
proposed_date: str
proposed_time: Optional[str] = None

Static files for dashboard

DASHBOARD_STATIC_DIR = Path("/home/hoffmann_admin/.openclaw/workspace-socrates/hoffdesk-api/dashboard/static")

@router.get("/family/login/", response_class=HTMLResponse)
async def family_login_page(request: Request):
"""Serve the family login page."""
# Already authenticated? Redirect to dashboard.
if is_authenticated(request):
return RedirectResponse(url="/", status_code=302)

return templates.TemplateResponse(
    request,
    "family_login.html.j2",
    {}
)

@router.get("/", response_class=HTMLResponse)
async def dashboard_root(request: Request, view: str = None):
"""Serve family dashboard at root path (for family.hoffdesk.com).

Parameters:
    view (str): Optional view mode - 'list' or 'week'
"""
if not is_authenticated(request):
    return RedirectResponse(url="/family/login/?redirect=/", status_code=302)

# Route to appropriate view
if view == "week":
    return await week_view_page(request)

return await dashboard_page(request)

@router.get("/dashboard/", response_class=HTMLResponse)
async def dashboard_page(request: Request):
"""Serve the family dashboard HTML."""
if not is_authenticated(request):
return RedirectResponse(url="/family/login/?redirect=/dashboard/", status_code=302)

# Use Jinja2 template for the dashboard
return templates.TemplateResponse(
    request,
    "dashboard.html.j2",
    {}
)

@router.get("/week", response_class=HTMLResponse)
async def week_view_page(request: Request):
"""Serve the week view HTML."""
if not is_authenticated(request):
return RedirectResponse(url="/family/login/?redirect=/week", status_code=302)

# Use Jinja2 template for week view
return templates.TemplateResponse(
    request,
    "week_view.html.j2",
    {}
)

@router.get("/api/today")
async def api_today(request: Request):
"""Return today's data: calendar, weather, health.

Public endpoint — consumed by HTMX polling on the dashboard.
No auth required since data is non-sensitive (calendar, weather, health).
"""
# Get real calendar data from Radicale
calendar_data = await _get_calendar_today()

# Get weather data
weather_data = await _get_weather()

# Get system health
health_data = await _get_system_health()

# Get Event Graph data
events_data = await _get_event_graph()

return {
    "timestamp": datetime.now().isoformat(),
    "calendar": calendar_data,
    "weather": weather_data,
    "health": health_data,
    "events": events_data,
    "meta": {
        "cache_ttl_seconds": 60,
        "data_freshness": {
            "calendar_seconds_ago": 0,
            "weather_seconds_ago": 0
        }
    }
}

@router.get("/api/events-dashboard")
async def api_events_dashboard(request: Request):
"""Return Event Graph HTML for dashboard — upcoming events with action buttons.

Public endpoint — consumed by HTMX polling on the dashboard.
Returns rendered HTML instead of JSON so buttons work immediately.
"""
events_data = await _get_event_graph()

# Load events.html template
events_template_path = DASHBOARD_TEMPLATES_DIR / "events.html"
if events_template_path.exists():
    template = Template(events_template_path.read_text())
    html_content = template.render(events=events_data)
    return HTMLResponse(content=html_content)

# Fallback to JSON if template missing
return {
    "timestamp": datetime.now().isoformat(),
    "events": events_data,
    "meta": {
        "source": "event-graph-api",
        "cache_ttl_seconds": 15
    }
}

async def _get_calendar_today():
"""Fetch today's events from Radicale."""
try:
import caldav
import os

    # Support both CALDAV_* and RADICALE_* env vars
    host = os.getenv('RADICALE_HOST') or os.getenv('CALDAV_URL', '127.0.0.1')
    # Extract host from URL if needed
    if host.startswith('http://'):
        host = host[7:]  # strip http://
    if ':' in host:
        host = host.split(':')[0]

    port = os.getenv('RADICALE_PORT') or '5232'
    user = os.getenv('RADICALE_USER') or os.getenv('CALDAV_USER', 'assistant')
    password = os.getenv('RADICALE_PASS') or os.getenv('CALDAV_PASSWORD', 'family-assistant-2026')

    client = caldav.DAVClient(
        url=f"http://{host}:{port}/",
        username=user,
        password=password
    )

    principal = client.principal()
    calendars = principal.calendars()

    # Get family calendar
    family_cal = None
    for cal in calendars:
        if 'family' in cal.name.lower():
            family_cal = cal
            break

    if not family_cal:
        family_cal = calendars[0] if calendars else None

    if family_cal:
        # Get events for today
        from datetime import date, timedelta
        today = date.today()
        tomorrow = today + timedelta(days=1)

        events_data = family_cal.date_search(today, tomorrow)
        events = []

        for event in events_data:
            vevent = event.instance.vevent
            events.append({
                "uid": str(vevent.uid.value) if hasattr(vevent, 'uid') else "",
                "summary": str(vevent.summary.value) if hasattr(vevent, 'summary') else "",
                "start": vevent.dtstart.value.isoformat() if hasattr(vevent, 'dtstart') else "",
                "end": vevent.dtend.value.isoformat() if hasattr(vevent, 'dtend') else "",
                "is_all_day": not isinstance(vevent.dtstart.value, datetime) if hasattr(vevent, 'dtstart') else False,
                "location": str(vevent.location.value) if hasattr(vevent, 'location') else "",
                "attendees": []
            })

        return {"status": "ok", "events": events}

    return {"status": "error", "message": "No calendar found", "events": []}

except Exception as e:
    return {"status": "error", "message": str(e), "events": []}

async def _get_weather():
"""Fetch weather data."""
try:
import httpx

    # Use wttr.in for free weather (no API key needed)
    async with httpx.AsyncClient(timeout=10.0) as client:
        response = await client.get("https://wttr.in/Green+Bay,WI?format=j1")
        if response.status_code == 200:
            data = response.json()
            current = data.get("current_condition", [{}])[0]

            # Build forecast
            forecast = []
            for hour in data.get("weather", [{}])[0].get("hourly", [])[:12]:
                forecast.append({
                    "hour": f"{hour.get('time', '0000')[:2]}:00",
                    "temperature_f": int(hour.get("tempF", 0)),
                    "condition": hour.get("weatherDesc", [{}])[0].get("value", ""),
                    "precipitation_chance_pct": int(hour.get("chanceofrain", 0))
                })

            return {
                "status": "ok",
                "current": {
                    "temperature_f": int(current.get("temp_F", 0)),
                    "feels_like_f": int(current.get("FeelsLikeF", 0)),
                    "condition": current.get("weatherDesc", [{}])[0].get("value", ""),
                    "humidity_pct": int(current.get("humidity", 0)),
                    "wind_mph": int(current.get("windspeedMiles", 0))
                },
                "forecast": forecast
            }
except Exception as e:
    pass

# Fallback data
return {
    "status": "fallback",
    "current": {
        "temperature_f": 52,
        "feels_like_f": 49,
        "condition": "Data unavailable",
        "humidity_pct": 62,
        "wind_mph": 10
    },
    "forecast": []
}

async def _get_system_health():
"""Check system health."""
import os
import psutil

components = {}

# Check OpenClaw gateway
try:
    import httpx
    async with httpx.AsyncClient() as client:
        start = datetime.now()
        response = await client.get("http://127.0.0.1:18789/health", timeout=2.0)
        latency = (datetime.now() - start).total_seconds() * 1000
        components["openclaw_gateway"] = {
            "status": "ok" if response.status_code == 200 else "degraded",
            "latency_ms": round(latency, 1)
        }
except:
    components["openclaw_gateway"] = {"status": "error", "latency_ms": 0}

# Check Radicale
try:
    start = datetime.now()
    import socket
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(2)
    result = sock.connect_ex((os.getenv('RADICALE_HOST', '127.0.0.1'), int(os.getenv('RADICALE_PORT', '5232'))))
    latency = (datetime.now() - start).total_seconds() * 1000
    components["radicale_caldav"] = {
        "status": "ok" if result == 0 else "error",
        "latency_ms": round(latency, 1)
    }
    sock.close()
except:
    components["radicale_caldav"] = {"status": "error", "latency_ms": 0}

# Check Ollama
try:
    start = datetime.now()
    async with httpx.AsyncClient() as client:
        response = await client.get("http://100.104.147.116:11434/api/tags", timeout=3.0)
        latency = (datetime.now() - start).total_seconds() * 1000
        components["ollama_local"] = {
            "status": "ok" if response.status_code == 200 else "error",
            "latency_ms": round(latency, 1)
        }
except:
    components["ollama_local"] = {"status": "error", "latency_ms": 0}

# Check Cloudflare tunnel
try:
    start = datetime.now()
    import subprocess
    result = subprocess.run(['systemctl', 'is-active', 'cloudflared'], capture_output=True, text=True)
    latency = (datetime.now() - start).total_seconds() * 1000
    components["cloudflare_tunnel"] = {
        "status": "ok" if result.returncode == 0 else "error",
        "latency_ms": round(latency, 1)
    }
except:
    components["cloudflare_tunnel"] = {"status": "unknown", "latency_ms": 0}

# Check IMAP Proxy
try:
    import httpx
    async with httpx.AsyncClient() as client:
        start = datetime.now()
        response = await client.get("http://127.0.0.1:8000/imap/status", timeout=3.0)
        latency = (datetime.now() - start).total_seconds() * 1000
        if response.status_code == 200:
            imap_data = response.json()
            imap_status = "ok" if imap_data.get("status") == "connected" else "degraded" if imap_data.get("status") == "not_configured" else "error"
        else:
            imap_status = "error"
        components["imap_proxy"] = {
            "status": imap_status,
            "latency_ms": round(latency, 1)
        }
except:
    components["imap_proxy"] = {"status": "not_configured", "latency_ms": 0}

# Calculate overall status
overall = "healthy"
for comp in components.values():
    if comp["status"] == "error":
        overall = "degraded"
        break

# Disk usage
disk = psutil.disk_usage('/')

return {
    "overall_status": overall,
    "components": components,
    "disk_usage": {
        "total_gb": round(disk.total / (1024**3), 1),
        "used_gb": round(disk.used / (1024**3), 1),
        "usage_pct": round(disk.percent, 1)
    }
}

async def _get_event_graph():
"""Fetch upcoming events from Event Graph API or test data."""
try:
# Try Event Graph API first
import httpx
async with httpx.AsyncClient() as client:
response = await client.get(
"http://localhost:8002/api/v1/events?limit=10",
timeout=2.0
)
if response.status_code == 200:
events = response.json()
# Format for dashboard
now = datetime.now()
formatted = []
for e in events[:8]:
start = e.get("start_date", "")
# Determine if today, tomorrow, or future
day_label = ""
try:
from datetime import date
ev_date = datetime.fromisoformat(start.replace("Z", "+00:00")).date()
today = date.today()
if ev_date == today:
day_label = "Today"
elif ev_date == today.replace(day=today.day + 1):
day_label = "Tomorrow"
else:
day_label = ev_date.strftime("%a %b %d")
except Exception:
day_label = start[:10] if start else ""

                formatted.append({
                    "id": e.get("id", ""),
                    "title": e.get("title", "Untitled"),
                    "start": start,
                    "day_label": day_label,
                    "status": e.get("status", "unknown"),
                    "event_type": e.get("event_type", "calendar"),
                    "confidence": e.get("confidence", 0),
                    "location": e.get("location", ""),
                })
            return formatted
except Exception:
    pass

# Fallback: Load test events from JSON file
try:
    import json
    from pathlib import Path
    test_file = Path(__file__).parent.parent / "data" / "test_events.json"
    if test_file.exists():
        with open(test_file) as f:
            test_events = json.load(f)
        # Format for dashboard - filter out declined events
        formatted = []
        for e in test_events[:10]:
            # Skip declined events (they're hidden from dashboard)
            if e.get("status") == "declined":
                continue

            # Recalculate day_label based on current date
            start = e.get("start", "")
            day_label = ""
            try:
                from datetime import date
                ev_date = datetime.fromisoformat(start.replace("Z", "+00:00")).date()
                today = date.today()
                if ev_date == today:
                    day_label = "Today"
                elif ev_date == today + timedelta(days=1):
                    day_label = "Tomorrow"
                else:
                    day_label = ev_date.strftime("%a %b %d")
            except Exception:
                day_label = start[:10] if start else ""

            # Check for conflicts with other events
            # Only show conflicts for non-confirmed events (needs_confirmation, pending)
            # Confirmed events: user accepted the conflict, stay silent
            # Rescheduled back to pending: conflicts re-appear
            conflicts = []
            family_members = e.get("family_members", [])
            if e.get("status") in ("needs_confirmation", "pending"):
                for other in test_events:
                    if other.get("id") != e.get("id") and other.get("status") != "declined":
                        other_date = other.get("start", "")
                        other_members = other.get("family_members", [])
                        if other_date and other_date == start:
                            # Determine conflict severity
                            if family_members and other_members:
                                shared = set(family_members) & set(other_members)
                                if shared:
                                    conflicts.append({
                                        "id": other.get("id", ""),
                                        "title": other.get("title", "Untitled"),
                                        "severity": "person",
                                        "shared_members": list(shared)
                                    })
                                else:
                                    conflicts.append({
                                        "id": other.get("id", ""),
                                        "title": other.get("title", "Untitled"),
                                        "severity": "same_day"
                                    })
                            else:
                                conflicts.append({
                                    "id": other.get("id", ""),
                                    "title": other.get("title", "Untitled"),
                                    "severity": "same_day"
                                })

            formatted.append({
                "id": e.get("id", ""),
                "title": e.get("title", "Untitled"),
                "start": start,
                "day_label": day_label,
                "status": e.get("status", "unknown"),
                "event_type": e.get("event_type", "calendar"),
                "confidence": e.get("confidence", 0.95),
                "location": e.get("location", ""),
                "family_members": family_members,
                "conflicts": conflicts,
            })
        return formatted
except Exception:
    pass

return []

def _load_test_events():
"""Load test events from JSON file."""
if TEST_EVENTS_FILE.exists():
with open(TEST_EVENTS_FILE, 'r') as f:
return json.load(f)
return []

def _save_test_events(events):
"""Save test events to JSON file."""
with open(TEST_EVENTS_FILE, 'w') as f:
json.dump(events, f, indent=2)

def _find_event_by_id(events, event_id: str):
"""Find an event by ID."""
for event in events:
if event.get("id") == event_id:
return event
return None

@router.get("/api/events/{event_id}")
async def api_get_event(event_id: str, request: Request):
"""Get a single event by ID — returns HTML for HTMX modal requests."""
events = _load_test_events()
event = _find_event_by_id(events, event_id)

if not event:
    raise HTTPException(status_code=404, detail=f"Event {event_id} not found")

# Calculate day_label
start = event.get("start", "")
day_label = ""
try:
    from datetime import date
    ev_date = datetime.fromisoformat(start.replace("Z", "+00:00")).date()
    today = date.today()
    if ev_date == today:
        day_label = "Today"
    elif ev_date == today + timedelta(days=1):
        day_label = "Tomorrow"
    else:
        day_label = ev_date.strftime("%a %b %d")
except Exception:
    day_label = start[:10] if start else ""

event["day_label"] = day_label

# Check if HTMX request
is_htmx = request.headers.get("HX-Request") == "true"

if is_htmx:
    # Load and render the event detail template
    event_detail_path = DASHBOARD_TEMPLATES_DIR / "partials" / "event_detail.html"
    if event_detail_path.exists():
        template = Template(event_detail_path.read_text())
        html_content = template.render(event=event)
        return HTMLResponse(content=html_content)

# Return JSON for non-HTMX requests
return event

@router.post("/api/events/{event_id}/confirm")
async def api_event_confirm(event_id: str, request: Request):
"""Confirm an event — transitions status to confirmed."""
events = _load_test_events()
event = _find_event_by_id(events, event_id)

if not event:
    raise HTTPException(status_code=404, detail=f"Event {event_id} not found")

# Update status
event["status"] = "confirmed"
event["updated_at"] = datetime.now().isoformat()

_save_test_events(events)

# Re-render the event card HTML for HTMX swap
events_template_path = DASHBOARD_TEMPLATES_DIR / "dashboard" / "components" / "events_list.html.j2"
if events_template_path.exists():
    from jinja2 import Template
    template = Template(events_template_path.read_text())
    html_content = template.render(events=[event])
    return HTMLResponse(content=html_content)

# Fallback to JSON if template missing
return {
    "status": "success",
    "event_id": event_id,
    "new_status": "confirmed",
    "message": "Event confirmed"
}

@router.post("/api/events/{event_id}/decline")
async def api_event_decline(event_id: str, request: Request):
"""Decline an event — transitions status to declined (hidden from dashboard)."""
events = _load_test_events()
event = _find_event_by_id(events, event_id)

if not event:
    raise HTTPException(status_code=404, detail=f"Event {event_id} not found")

# Update status with soft-delete timestamp
event["status"] = "declined"
event["updated_at"] = datetime.now().isoformat()
event["removed_at"] = datetime.now().isoformat()

_save_test_events(events)

# Re-render the event card HTML for HTMX swap (will be empty since declined)
events_template_path = DASHBOARD_TEMPLATES_DIR / "dashboard" / "components" / "events_list.html.j2"
if events_template_path.exists():
    from jinja2 import Template
    template = Template(events_template_path.read_text())
    html_content = template.render(events=[event])
    return HTMLResponse(content=html_content)

# Fallback to JSON if template missing
return {
    "status": "success",
    "event_id": event_id,
    "new_status": "declined",
    "message": "Event declined and hidden from dashboard"
}

@router.post("/api/events/{event_id}/reschedule")
async def api_event_reschedule(event_id: str, request: Request):
"""Reschedule an event to a new date/time — transitions status to pending.

Supports both HTMX (form data) and JSON requests.
"""
events = _load_test_events()
event = _find_event_by_id(events, event_id)

if not event:
    raise HTTPException(status_code=404, detail=f"Event {event_id} not found")

# Parse request body (JSON or form data)
try:
    body = await request.json()
    new_date = body.get("new_date")
    new_time = body.get("new_time")
except Exception:
    # Try form data
    form = await request.form()
    new_date = form.get("new_date")
    new_time = form.get("new_time")

# Update date/time and status
if new_date:
    event["start"] = new_date
if new_time:
    event["time"] = new_time
event["status"] = "pending"
event["updated_at"] = datetime.now().isoformat()

# Check for conflicts with other events on same date
conflicts = []
for other in events:
    if other.get("id") != event_id and other.get("status") != "declined":
        # Simple date-based conflict detection
        other_date = other.get("start", "")
        if other_date and other_date == new_date:
            conflicts.append({
                "title": other.get("title", "Untitled"),
                "id": other.get("id", ""),
                "date": other_date
            })

# Add conflict warning if any found
if conflicts:
    event["conflict_warning"] = f"⚠️ Same day as: {', '.join(c['title'] for c in conflicts)}"
else:
    event.pop("conflict_warning", None)

_save_test_events(events)

# Re-render the event card HTML for HTMX swap
events_template_path = DASHBOARD_TEMPLATES_DIR / "dashboard" / "components" / "events_list.html.j2"
if events_template_path.exists():
    from jinja2 import Template
    template = Template(events_template_path.read_text())
    html_content = template.render(events=[event])
    return HTMLResponse(content=html_content)

# Fallback to JSON if template missing
return {
    "status": "success",
    "event_id": event_id,
    "new_status": "pending",
    "new_date": new_date,
    "new_time": new_time,
    "conflicts": conflicts,
    "message": "Event rescheduled and set to pending"
}

class ModifyRequest(BaseModel):
title: Optional[str] = None
new_date: str
new_time: Optional[str] = None
location: Optional[str] = None

@router.get("/api/events/{event_id}/reschedule-form")
async def api_event_reschedule_form(event_id: str, request: Request):
"""Return reschedule form HTML for HTMX swap."""
events = _load_test_events()
event = _find_event_by_id(events, event_id)

if not event:
    raise HTTPException(status_code=404, detail=f"Event {event_id} not found")

# Extract date and time from start field
start = event.get("start", "")
current_date = start[:10] if start else datetime.now().strftime("%Y-%m-%d")
current_time = event.get("time", "12:00")[:5] if event.get("time") else "12:00"

# Generate HTML form
html_content = f'''\n<div class="event-card" id="event-{event_id}">

Reschedule: {event.get("title", "Untitled")}

'''

return HTMLResponse(content=html_content)

@router.get("/api/events/{event_id}/modify-form")
async def api_event_modify_form(event_id: str, request: Request):
"""Return modify form HTML for HTMX swap."""
events = _load_test_events()
event = _find_event_by_id(events, event_id)

if not event:
    raise HTTPException(status_code=404, detail=f"Event {event_id} not found")

# Extract date and time from start field
start = event.get("start", "")
current_date = start[:10] if start else datetime.now().strftime("%Y-%m-%d")
current_time = event.get("time", "12:00")[:5] if event.get("time") else "12:00"
current_title = event.get("title", "")
current_location = event.get("location", "")

# Generate HTML form
html_content = f'''\n<div class="event-card" id="event-{event_id}">

Modify Event

'''

return HTMLResponse(content=html_content)

@router.get("/api/events/{event_id}/counter-form")
async def api_event_counter_form(event_id: str, request: Request):
"""Return counter-proposal form HTML for HTMX swap."""
events = _load_test_events()
event = _find_event_by_id(events, event_id)

if not event:
    raise HTTPException(status_code=404, detail=f"Event {event_id} not found")

# Get suggested date (tomorrow as default)
suggested_date = (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%d")

# Generate HTML form
html_content = f'''\n<div class="event-card" id="event-{event_id}">

Counter-propose: {event.get("title", "Untitled")}

'''

return HTMLResponse(content=html_content)

@router.post("/api/events/{event_id}/modify")
async def api_event_modify(event_id: str, request: Request):
"""Modify event details — title, date, time, location."""
events = _load_test_events()
event = _find_event_by_id(events, event_id)

if not event:
    raise HTTPException(status_code=404, detail=f"Event {event_id} not found")

# Parse request body (JSON or form data)
try:
    body = await request.json()
    title = body.get("title")
    new_date = body.get("new_date")
    new_time = body.get("new_time")
    location = body.get("location")
except Exception:
    # Try form data
    form = await request.form()
    title = form.get("title")
    new_date = form.get("new_date")
    new_time = form.get("new_time")
    location = form.get("location")

# Update fields
if title is not None:
    event["title"] = title
if new_date:
    event["start"] = new_date
if new_time:
    event["time"] = new_time
if location is not None:
    event["location"] = location
event["updated_at"] = datetime.now().isoformat()

_save_test_events(events)

# Re-render the event card HTML for HTMX swap
events_template_path = DASHBOARD_TEMPLATES_DIR / "dashboard" / "components" / "events_list.html.j2"
if events_template_path.exists():
    from jinja2 import Template
    template = Template(events_template_path.read_text())
    html_content = template.render(events=[event])
    return HTMLResponse(content=html_content)

# Fallback to JSON if template missing
return {
    "status": "success",
    "event_id": event_id,
    "message": "Event modified successfully"
}

@router.post("/api/events/{event_id}/counter")
async def api_event_counter(event_id: str, request: Request):
"""Counter-propose a new date/time — transitions status to pending with counter-proposal note.

Supports both HTMX (form data) and JSON requests.
"""
events = _load_test_events()
event = _find_event_by_id(events, event_id)

if not event:
    raise HTTPException(status_code=404, detail=f"Event {event_id} not found")

# Parse request body (JSON or form data)
try:
    body = await request.json()
    proposed_date = body.get("proposed_date")
    proposed_time = body.get("proposed_time")
    message = body.get("message")
except Exception:
    # Try form data
    form = await request.form()
    proposed_date = form.get("proposed_date")
    proposed_time = form.get("proposed_time")
    message = form.get("message")

# Update status and add counter-proposal note
event["status"] = "pending"
note_text = f"Counter-proposed: {proposed_date}" + (f" at {proposed_time}" if proposed_time else "")
if message:
    note_text += f"  {message}"
event["notes"] = note_text
event["counter_proposed_date"] = proposed_date
if proposed_time:
    event["counter_proposed_time"] = proposed_time
event["updated_at"] = datetime.now().isoformat()

_save_test_events(events)

# Re-render the event card HTML for HTMX swap
events_template_path = DASHBOARD_TEMPLATES_DIR / "dashboard" / "components" / "events_list.html.j2"
if events_template_path.exists():
    from jinja2 import Template
    template = Template(events_template_path.read_text())
    html_content = template.render(events=[event])
    return HTMLResponse(content=html_content)

# Fallback to JSON if template missing
return {
    "status": "success",
    "event_id": event_id,
    "new_status": "pending",
    "counter_proposed_date": proposed_date,
    "counter_proposed_time": proposed_time,
    "notes": event["notes"],
    "message": "Counter-proposal submitted and event set to pending"
}

@router.get("/family/events/removed")
async def family_events_removed(request: Request):
"""Return events removed (declined) in the last 24 hours.

Public endpoint  consumed by dashboard widget.
"""
events = _load_test_events()

# Filter events removed in the last 24 hours
now = datetime.now()
removed_events = []

for event in events:
    if event.get("status") == "declined" and event.get("removed_at"):
        try:
            removed_at = datetime.fromisoformat(event["removed_at"].replace("Z", "+00:00"))
            # Remove timezone for comparison
            if removed_at.tzinfo:
                removed_at = removed_at.replace(tzinfo=None)
            hours_since = (now - removed_at).total_seconds() / 3600
            if hours_since <= 24:
                removed_events.append(event)
        except Exception:
            continue

# Sort by removed_at descending (most recent first)
removed_events.sort(key=lambda e: e.get("removed_at", ""), reverse=True)

return {
    "timestamp": now.isoformat(),
    "events": removed_events,
    "count": len(removed_events),
    "meta": {
        "window_hours": 24,
        "cache_ttl_seconds": 30
    }
}

@router.post("/family/events/{slug}/restore")
async def family_event_restore(slug: str, request: Request):
"""Restore a declined event back to active status.

Removes the 'removed' status and clears removed_at timestamp.
Returns the restored event card HTML for HTMX swap.
"""
events = _load_test_events()
event = _find_event_by_id(events, slug)

if not event:
    raise HTTPException(status_code=404, detail=f"Event {slug} not found")

if event.get("status") != "declined":
    raise HTTPException(status_code=400, detail=f"Event {slug} is not removed")

# Restore event - clear removed status and timestamp
event["status"] = "pending"
event.pop("removed_at", None)
event["updated_at"] = datetime.now().isoformat()
event["restored_at"] = datetime.now().isoformat()

_save_test_events(events)

# Re-render the event card HTML for HTMX swap
events_template_path = DASHBOARD_TEMPLATES_DIR / "events.html"
if events_template_path.exists():
    from jinja2 import Template
    template = Template(events_template_path.read_text())
    html_content = template.render(events=[event])
    return HTMLResponse(content=html_content)

# Fallback to JSON if template missing
return {
    "status": "success",
    "event_id": slug,
    "new_status": "pending",
    "message": "Event restored"
}

Family member color mapping

FAMILY_MEMBER_COLORS = {
"Matt": "#4A90D9",
"Aundrea": "#E57373",
"Sullivan": "#81C784",
"Harper": "#FFD54F",
}

def _get_day_label(date_str: str) -> str:
"""Get human-friendly day label for a date."""
if not date_str:
return ""
try:
from datetime import date
ev_date = datetime.fromisoformat(date_str.replace("Z", "+00:00")).date()
today = date.today()
if ev_date == today:
return "Today"
elif ev_date == today + timedelta(days=1):
return "Tomorrow"
else:
return ev_date.strftime("%a %b %d")
except Exception:
return date_str[:10] if date_str else ""

def _format_event_for_list(event: Dict[str, Any]) -> Dict[str, Any]:
"""Format an event for the list view with all necessary fields."""
# Calculate conflicts for the event
all_events = _load_test_events()
conflicts = []
start = event.get("start", "")
family_members = event.get("family_members", [])

if event.get("status") in ("needs_confirmation", "pending"):
    for other in all_events:
        if other.get("id") != event.get("id") and other.get("status") != "declined":
            other_date = other.get("start", "")
            other_members = other.get("family_members", [])
            if other_date and other_date == start:
                if family_members and other_members:
                    shared = set(family_members) & set(other_members)
                    if shared:
                        conflicts.append({
                            "id": other.get("id", ""),
                            "title": other.get("title", "Untitled"),
                            "severity": "same_person",
                            "shared_members": list(shared)
                        })
                    else:
                        conflicts.append({
                            "id": other.get("id", ""),
                            "title": other.get("title", "Untitled"),
                            "severity": "same_day",
                        })
                else:
                    conflicts.append({
                        "id": other.get("id", ""),
                        "title": other.get("title", "Untitled"),
                        "severity": "same_day",
                    })

return {
    "id": event.get("id", ""),
    "title": event.get("title", "Untitled"),
    "start": start,
    "day_label": _get_day_label(start),
    "status": event.get("status", "unknown"),
    "event_type": event.get("event_type", "calendar"),
    "location": event.get("location", ""),
    "family_members": family_members,
    "conflicts": conflicts,
    "time": event.get("time", ""),
    "counter_proposed_date": event.get("counter_proposed_date", ""),
    "counter_proposed_time": event.get("counter_proposed_time", ""),
}

@router.get("/api/events-list")
async def api_events_list_deprecated(request: Request):
"""DEPRECATED: Use /ui/events-list instead.

Returns JSON for backward compatibility. HTMX should call /ui/events-list.
"""
# Load and filter events (exclude declined)
events = _load_test_events()
filtered_events = []

for event in events:
    if event.get("status") != "declined":
        filtered_events.append(_format_event_for_list(event))

# Sort by start date
filtered_events.sort(key=lambda e: e.get("start", ""))

return JSONResponse(
    content={
        "events": filtered_events,
        "meta": {
            "note": "This endpoint is deprecated. Use /ui/events-list for HTML fragments.",
            "count": len(filtered_events)
        }
    }
)

@router.get("/api/events-week")
async def api_events_week_deprecated(request: Request, start: str = None):
"""DEPRECATED: Use /ui/events-week instead.

Returns JSON for backward compatibility. HTMX should call /ui/events-week.
"""
# Determine week start date
if start:
    try:
        week_start = datetime.strptime(start, "%Y-%m-%d")
        week_start = _get_week_start(week_start)
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid start date format. Use YYYY-MM-DD")
else:
    week_start = _get_week_start()

week_end = week_start + timedelta(days=6)

# Load events
events = _load_test_events()

# Build days array (simplified for JSON response)
today = datetime.now().date()
day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]

days = []
for i in range(7):
    day_date = week_start + timedelta(days=i)
    day_date_str = day_date.strftime("%Y-%m-%d")

    # Get events for this day
    day_events = []
    for event in events:
        if event.get("status") == "declined":
            continue
        if event.get("start") == day_date_str:
            day_events.append({
                "slug": event.get("id", ""),
                "title": event.get("title", "Untitled"),
                "time": event.get("time", ""),
                "status": event.get("status", "unknown"),
            })

    days.append({
        "date": day_date_str,
        "day_name": day_names[i],
        "is_today": day_date.date() == today,
        "events": day_events
    })

return JSONResponse(
    content={
        "week_start": week_start.strftime("%Y-%m-%d"),
        "week_end": week_end.strftime("%Y-%m-%d"),
        "days": days,
        "meta": {
            "note": "This endpoint is deprecated. Use /ui/events-week for HTML fragments."
        }
    }
)

def _get_week_start(date_obj=None) -> datetime:
"""Get the Monday of the week containing the given date (or today)."""
if date_obj is None:
date_obj = datetime.now()
# Monday is 0, Sunday is 6
days_since_monday = date_obj.weekday()
week_start = date_obj - timedelta(days=days_since_monday)
return week_start.replace(hour=0, minute=0, second=0, microsecond=0)

def _parse_event_date(event: Dict[str, Any]) -> datetime:
"""Parse event start date to datetime."""
start_str = event.get("start", "")
if not start_str:
return None
try:
# Handle ISO format with or without time
if "T" in start_str:
return datetime.fromisoformat(start_str.replace("Z", "+00:00"))
else:
return datetime.strptime(start_str, "%Y-%m-%d")
except Exception:
return None

def _detect_conflicts(events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Detect conflicts between events (same day + overlapping times)."""
conflicts = []

for i, event1 in enumerate(events):
    if event1.get("status") == "declined":
        continue

    for event2 in events[i+1:]:
        if event2.get("status") == "declined":
            continue

        # Check if same day
        date1 = event1.get("start", "")
        date2 = event2.get("start", "")

        if date1 != date2:
            continue

        # Get times
        time1 = event1.get("time", "")
        time2 = event2.get("time", "")
        duration1 = event1.get("duration_minutes", 60)
        duration2 = event2.get("duration_minutes", 60)

        # If no time specified, treat as all-day (no time conflict)
        if not time1 or not time2:
            # Still check for person conflicts
            members1 = set(event1.get("family_members", []))
            members2 = set(event2.get("family_members", []))
            shared = members1 & members2

            if shared:
                conflicts.append({
                    "event1_slug": event1.get("id", ""),
                    "event2_slug": event2.get("id", ""),
                    "reason": f"Same day, shared family members: {', '.join(shared)}",
                    "severity": "person"
                })
            continue

        # Parse times and check for overlap
        try:
            t1_start = datetime.strptime(time1, "%H:%M")
            t2_start = datetime.strptime(time2, "%H:%M")
            t1_end = t1_start + timedelta(minutes=duration1)
            t2_end = t2_start + timedelta(minutes=duration2)

            # Check if times overlap (comparing only time parts)
            # Convert to minutes since midnight for easier comparison
            t1_start_mins = t1_start.hour * 60 + t1_start.minute
            t1_end_mins = t1_end.hour * 60 + t1_end.minute
            t2_start_mins = t2_start.hour * 60 + t2_start.minute
            t2_end_mins = t2_end.hour * 60 + t2_end.minute

            # Overlap: if start1 < end2 and start2 < end1
            times_overlap = t1_start_mins < t2_end_mins and t2_start_mins < t1_end_mins

            if times_overlap:
                # Check if same people
                members1 = set(event1.get("family_members", []))
                members2 = set(event2.get("family_members", []))
                shared = members1 & members2

                if shared:
                    conflicts.append({
                        "event1_slug": event1.get("id", ""),
                        "event2_slug": event2.get("id", ""),
                        "reason": f"Same day, overlapping time, shared family members: {', '.join(shared)}",
                        "severity": "time_conflict"
                    })
                else:
                    conflicts.append({
                        "event1_slug": event1.get("id", ""),
                        "event2_slug": event2.get("id", ""),
                        "reason": "Same day, overlapping time",
                        "severity": "time_conflict"
                    })
        except Exception:
            continue

return conflicts

@router.get("/api/week")
async def api_week(request: Request, start: str = None):
"""Return week view data: 7 days of events.

Parameters:
    start (str): Optional ISO date (YYYY-MM-DD) for week start (Monday)
                If not provided, uses current week's Monday

Returns:
    JSON with week_start, week_end, days (array), and conflicts
"""
# Determine week start date
if start:
    try:
        week_start = datetime.strptime(start, "%Y-%m-%d")
        # Ensure it's a Monday
        week_start = _get_week_start(week_start)
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid start date format. Use YYYY-MM-DD")
else:
    week_start = _get_week_start()

week_end = week_start + timedelta(days=6)

# Load all events
events = _load_test_events()

# Filter events for this week (not declined, and within date range)
week_events = []
for event in events:
    if event.get("status") == "declined":
        continue

    event_date = _parse_event_date(event)
    if not event_date:
        continue

    # Normalize to date only for comparison
    event_date_only = event_date.date()
    week_start_date = week_start.date()
    week_end_date = week_end.date()

    # Include if within the week
    if week_start_date <= event_date_only <= week_end_date:
        week_events.append(event)

# Sort events by date and time
def event_sort_key(e):
    date_part = e.get("start", "")
    time_part = e.get("time", "00:00")
    try:
        return (date_part, time_part)
    except Exception:
        return (date_part, "")

week_events.sort(key=event_sort_key)

# Build days array
today = datetime.now().date()
days = []
day_names = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]

for i in range(7):
    day_date = week_start + timedelta(days=i)
    day_date_str = day_date.strftime("%Y-%m-%d")

    # Get events for this day
    day_events = []
    for event in week_events:
        if event.get("start") == day_date_str:
            # Format event for response
            family_members = event.get("family_members", [])
            status = event.get("status", "unknown")

            # Determine needs_confirmation
            needs_confirmation = status in ["needs_confirmation", "pending"]

            # Check if this event has conflicts with other week events
            has_conflict = False
            other_events = [e for e in week_events if e.get("id") != event.get("id")]
            for conflict in _detect_conflicts([event] + other_events):
                if event.get("id") in [conflict["event1_slug"], conflict["event2_slug"]]:
                    has_conflict = True
                    break

            # Map to category
            category = "appointment"
            if "practice" in event.get("title", "").lower():
                category = "practice"
            elif "party" in event.get("title", "").lower():
                category = "party"
            elif "dentist" in event.get("title", "").lower():
                category = "appointment"
            elif "trip" in event.get("title", "").lower():
                category = "travel"

            day_events.append({
                "slug": event.get("id", ""),
                "title": event.get("title", "Untitled"),
                "time": event.get("time", ""),
                "duration_minutes": event.get("duration_minutes", 60),
                "family_members": family_members,
                "status": status,
                "needs_confirmation": needs_confirmation,
                "has_conflict": has_conflict,
                "category": category,
                "location": event.get("location", ""),
            })

    days.append({
        "date": day_date_str,
        "day_name": day_names[i],
        "is_today": day_date.date() == today,
        "events": day_events
    })

# Detect all conflicts for the week
conflicts = _detect_conflicts(week_events)

# Check if HTMX request
is_htmx = request.headers.get("HX-Request") == "true"

if is_htmx:
    # Load and render the week view content template
    week_content_path = DASHBOARD_TEMPLATES_DIR / "partials" / "week_view_content.html"
    if week_content_path.exists():
        from jinja2 import Template
        template = Template(week_content_path.read_text())

        # Render the template with the week data
        html_content = template.render(
            week_start=week_start.strftime("%Y-%m-%d"),
            week_end=week_end.strftime("%Y-%m-%d"),
            days=days,
            conflicts=conflicts
        )

        return HTMLResponse(content=html_content)

# Return JSON for non-HTMX requests
return {
    "week_start": week_start.strftime("%Y-%m-%d"),
    "week_end": week_end.strftime("%Y-%m-%d"),
    "days": days,
    "conflicts": conflicts
}
← Back