Tier 3: appointments CRUD + time tracking with topbar timer

This commit is contained in:
2026-02-28 04:38:56 +00:00
parent 82d03ce23a
commit 6ad642084d
13 changed files with 1075 additions and 0 deletions

View File

@@ -36,6 +36,8 @@ from routers import (
files as files_router,
meetings as meetings_router,
decisions as decisions_router,
weblinks as weblinks_router,
appointments as appointments_router,
)
@@ -179,3 +181,5 @@ app.include_router(lists_router.router)
app.include_router(files_router.router)
app.include_router(meetings_router.router)
app.include_router(decisions_router.router)
app.include_router(weblinks_router.router)
app.include_router(appointments_router.router)

View File

@@ -27,6 +27,9 @@ TRASH_ENTITIES = [
{"table": "meetings", "label": "Meetings", "name_col": "title", "url": "/meetings/{id}"},
{"table": "decisions", "label": "Decisions", "name_col": "title", "url": "/decisions/{id}"},
{"table": "files", "label": "Files", "name_col": "original_filename", "url": "/files"},
{"table": "weblinks", "label": "Weblinks", "name_col": "label", "url": "/weblinks"},
{"table": "weblink_folders", "label": "Weblink Folders", "name_col": "name", "url": "/weblinks"},
{"table": "appointments", "label": "Appointments", "name_col": "title", "url": "/appointments/{id}"},
{"table": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"},
{"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"},
]

302
routers/appointments.py Normal file
View File

@@ -0,0 +1,302 @@
"""Appointments CRUD: scheduling with contacts, recurrence, all-day support."""
from fastapi import APIRouter, Request, Depends, Form, Query
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 datetime import datetime
from core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/appointments", tags=["appointments"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_appointments(
request: Request,
status: Optional[str] = None,
timeframe: Optional[str] = "upcoming",
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
repo = BaseRepository("appointments", db)
# Build filter and sort based on timeframe
if timeframe == "past":
result = await db.execute(text("""
SELECT * FROM appointments
WHERE is_deleted = false AND start_at < now()
ORDER BY start_at DESC
LIMIT 100
"""))
elif timeframe == "all":
result = await db.execute(text("""
SELECT * FROM appointments
WHERE is_deleted = false
ORDER BY start_at DESC
LIMIT 200
"""))
else:
# upcoming (default)
result = await db.execute(text("""
SELECT * FROM appointments
WHERE is_deleted = false AND start_at >= CURRENT_DATE
ORDER BY start_at ASC
LIMIT 100
"""))
appointments = [dict(r._mapping) for r in result]
# Get contact counts per appointment
if appointments:
ids = [str(a["id"]) for a in appointments]
placeholders = ", ".join(f"'{i}'" for i in ids)
contact_result = await db.execute(text(f"""
SELECT appointment_id, count(*) as cnt
FROM contact_appointments
WHERE appointment_id IN ({placeholders})
GROUP BY appointment_id
"""))
contact_counts = {str(r._mapping["appointment_id"]): r._mapping["cnt"] for r in contact_result}
for a in appointments:
a["contact_count"] = contact_counts.get(str(a["id"]), 0)
count = len(appointments)
return templates.TemplateResponse("appointments.html", {
"request": request,
"sidebar": sidebar,
"appointments": appointments,
"count": count,
"timeframe": timeframe or "upcoming",
"page_title": "Appointments",
"active_nav": "appointments",
})
@router.get("/new")
async def new_appointment(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
# Load contacts for attendee selection
result = await db.execute(text("""
SELECT id, first_name, last_name, company
FROM contacts WHERE is_deleted = false
ORDER BY first_name, last_name
"""))
contacts = [dict(r._mapping) for r in result]
return templates.TemplateResponse("appointment_form.html", {
"request": request,
"sidebar": sidebar,
"appointment": None,
"contacts": contacts,
"selected_contacts": [],
"page_title": "New Appointment",
"active_nav": "appointments",
})
@router.post("/create")
async def create_appointment(
request: Request,
title: str = Form(...),
description: Optional[str] = Form(None),
location: Optional[str] = Form(None),
start_date: str = Form(...),
start_time: Optional[str] = Form(None),
end_date: Optional[str] = Form(None),
end_time: Optional[str] = Form(None),
all_day: Optional[str] = Form(None),
recurrence: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
contact_ids: Optional[list[str]] = Form(None),
db: AsyncSession = Depends(get_db),
):
is_all_day = all_day == "on"
# Build start_at
if is_all_day:
start_at = f"{start_date}T00:00:00"
else:
start_at = f"{start_date}T{start_time or '09:00'}:00"
# Build end_at
end_at = None
if end_date:
if is_all_day:
end_at = f"{end_date}T23:59:59"
else:
end_at = f"{end_date}T{end_time or '10:00'}:00"
data = {
"title": title,
"description": description or None,
"location": location or None,
"start_at": start_at,
"end_at": end_at,
"all_day": is_all_day,
"recurrence": recurrence or None,
"tags": [t.strip() for t in tags.split(",") if t.strip()] if tags else None,
}
repo = BaseRepository("appointments", db)
appointment = await repo.create(data)
# Add contact associations
if contact_ids:
for cid in contact_ids:
if cid:
await db.execute(text("""
INSERT INTO contact_appointments (contact_id, appointment_id)
VALUES (:cid, :aid)
ON CONFLICT DO NOTHING
"""), {"cid": cid, "aid": str(appointment["id"])})
return RedirectResponse(url=f"/appointments/{appointment['id']}", status_code=303)
@router.get("/{appointment_id}")
async def appointment_detail(
request: Request,
appointment_id: str,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
repo = BaseRepository("appointments", db)
appointment = await repo.get(appointment_id)
if not appointment:
return RedirectResponse(url="/appointments", status_code=303)
# Get associated contacts
result = await db.execute(text("""
SELECT c.id, c.first_name, c.last_name, c.company, c.email, ca.role
FROM contact_appointments ca
JOIN contacts c ON ca.contact_id = c.id
WHERE ca.appointment_id = :aid AND c.is_deleted = false
ORDER BY c.first_name, c.last_name
"""), {"aid": appointment_id})
contacts = [dict(r._mapping) for r in result]
return templates.TemplateResponse("appointment_detail.html", {
"request": request,
"sidebar": sidebar,
"appointment": appointment,
"contacts": contacts,
"page_title": appointment["title"],
"active_nav": "appointments",
})
@router.get("/{appointment_id}/edit")
async def edit_appointment_form(
request: Request,
appointment_id: str,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
repo = BaseRepository("appointments", db)
appointment = await repo.get(appointment_id)
if not appointment:
return RedirectResponse(url="/appointments", status_code=303)
# All contacts
result = await db.execute(text("""
SELECT id, first_name, last_name, company
FROM contacts WHERE is_deleted = false
ORDER BY first_name, last_name
"""))
contacts = [dict(r._mapping) for r in result]
# Currently linked contacts
result = await db.execute(text("""
SELECT contact_id FROM contact_appointments WHERE appointment_id = :aid
"""), {"aid": appointment_id})
selected_contacts = [str(r._mapping["contact_id"]) for r in result]
return templates.TemplateResponse("appointment_form.html", {
"request": request,
"sidebar": sidebar,
"appointment": appointment,
"contacts": contacts,
"selected_contacts": selected_contacts,
"page_title": f"Edit: {appointment['title']}",
"active_nav": "appointments",
})
@router.post("/{appointment_id}/edit")
async def update_appointment(
request: Request,
appointment_id: str,
title: str = Form(...),
description: Optional[str] = Form(None),
location: Optional[str] = Form(None),
start_date: str = Form(...),
start_time: Optional[str] = Form(None),
end_date: Optional[str] = Form(None),
end_time: Optional[str] = Form(None),
all_day: Optional[str] = Form(None),
recurrence: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
contact_ids: Optional[list[str]] = Form(None),
db: AsyncSession = Depends(get_db),
):
is_all_day = all_day == "on"
if is_all_day:
start_at = f"{start_date}T00:00:00"
else:
start_at = f"{start_date}T{start_time or '09:00'}:00"
end_at = None
if end_date:
if is_all_day:
end_at = f"{end_date}T23:59:59"
else:
end_at = f"{end_date}T{end_time or '10:00'}:00"
data = {
"title": title,
"description": description or None,
"location": location or None,
"start_at": start_at,
"end_at": end_at,
"all_day": is_all_day,
"recurrence": recurrence or None,
"tags": [t.strip() for t in tags.split(",") if t.strip()] if tags else None,
}
repo = BaseRepository("appointments", db)
await repo.update(appointment_id, data)
# Rebuild contact associations
await db.execute(text("DELETE FROM contact_appointments WHERE appointment_id = :aid"), {"aid": appointment_id})
if contact_ids:
for cid in contact_ids:
if cid:
await db.execute(text("""
INSERT INTO contact_appointments (contact_id, appointment_id)
VALUES (:cid, :aid)
ON CONFLICT DO NOTHING
"""), {"cid": cid, "aid": appointment_id})
return RedirectResponse(url=f"/appointments/{appointment_id}", status_code=303)
@router.post("/{appointment_id}/delete")
async def delete_appointment(
request: Request,
appointment_id: str,
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("appointments", db)
await repo.soft_delete(appointment_id)
return RedirectResponse(url="/appointments", status_code=303)

View File

@@ -137,6 +137,34 @@ SEARCH_ENTITIES = [
"url": "/decisions/{id}",
"icon": "decision",
},
{
"type": "weblinks",
"label": "Weblinks",
"query": """
SELECT w.id, w.label as name, NULL as status,
NULL as domain_name, NULL as project_name,
ts_rank(w.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM weblinks w
WHERE w.is_deleted = false AND w.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/weblinks",
"icon": "weblink",
},
{
"type": "appointments",
"label": "Appointments",
"query": """
SELECT a.id, a.title as name, NULL as status,
a.location as domain_name, NULL as project_name,
ts_rank(a.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM appointments a
WHERE a.is_deleted = false AND a.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/appointments/{id}",
"icon": "appointment",
},
]

233
routers/weblinks.py Normal file
View File

@@ -0,0 +1,233 @@
"""Weblinks: organized bookmark directory with recursive folders."""
from fastapi import APIRouter, Request, Form, Depends
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="/weblinks", tags=["weblinks"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_weblinks(
request: Request,
folder_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
# Get all folders for tree nav
result = await db.execute(text("""
SELECT wf.*, (SELECT count(*) FROM folder_weblinks fw WHERE fw.folder_id = wf.id) as link_count
FROM weblink_folders wf
WHERE wf.is_deleted = false
ORDER BY wf.sort_order, wf.name
"""))
all_folders = [dict(r._mapping) for r in result]
# Top-level folders and child folders
top_folders = [f for f in all_folders if f.get("parent_id") is None]
child_folder_map = {}
for f in all_folders:
pid = f.get("parent_id")
if pid:
child_folder_map.setdefault(str(pid), []).append(f)
# Current folder info
current_folder = None
if folder_id:
for f in all_folders:
if str(f["id"]) == folder_id:
current_folder = f
break
# Get weblinks (filtered by folder or all unfiled)
if folder_id:
result = await db.execute(text("""
SELECT w.* FROM weblinks w
JOIN folder_weblinks fw ON fw.weblink_id = w.id
WHERE fw.folder_id = :fid AND w.is_deleted = false
ORDER BY fw.sort_order, w.label
"""), {"fid": folder_id})
else:
# Show all weblinks
result = await db.execute(text("""
SELECT w.* FROM weblinks w
WHERE w.is_deleted = false
ORDER BY w.sort_order, w.label
"""))
items = [dict(r._mapping) for r in result]
return templates.TemplateResponse("weblinks.html", {
"request": request, "sidebar": sidebar, "items": items,
"top_folders": top_folders, "child_folder_map": child_folder_map,
"current_folder": current_folder,
"current_folder_id": folder_id or "",
"page_title": "Weblinks", "active_nav": "weblinks",
})
@router.get("/create")
async def create_form(
request: Request,
folder_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
result = await db.execute(text(
"SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name"
))
folders = [dict(r._mapping) for r in result]
return templates.TemplateResponse("weblink_form.html", {
"request": request, "sidebar": sidebar,
"folders": folders,
"page_title": "New Weblink", "active_nav": "weblinks",
"item": None,
"prefill_folder_id": folder_id or "",
})
@router.post("/create")
async def create_weblink(
request: Request,
label: str = Form(...),
url: str = Form(...),
description: Optional[str] = Form(None),
folder_id: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("weblinks", db)
data = {"label": label, "url": url, "description": description}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
weblink = await repo.create(data)
# Add to folder if specified
if folder_id and folder_id.strip():
await db.execute(text("""
INSERT INTO folder_weblinks (folder_id, weblink_id)
VALUES (:fid, :wid) ON CONFLICT DO NOTHING
"""), {"fid": folder_id, "wid": weblink["id"]})
redirect_url = f"/weblinks?folder_id={folder_id}" if folder_id and folder_id.strip() else "/weblinks"
return RedirectResponse(url=redirect_url, status_code=303)
@router.get("/{weblink_id}/edit")
async def edit_form(weblink_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("weblinks", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(weblink_id)
if not item:
return RedirectResponse(url="/weblinks", status_code=303)
result = await db.execute(text(
"SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name"
))
folders = [dict(r._mapping) for r in result]
# Current folder assignment
result = await db.execute(text(
"SELECT folder_id FROM folder_weblinks WHERE weblink_id = :wid LIMIT 1"
), {"wid": weblink_id})
row = result.first()
current_folder_id = str(row[0]) if row else ""
return templates.TemplateResponse("weblink_form.html", {
"request": request, "sidebar": sidebar,
"folders": folders,
"page_title": "Edit Weblink", "active_nav": "weblinks",
"item": item,
"prefill_folder_id": current_folder_id,
})
@router.post("/{weblink_id}/edit")
async def update_weblink(
weblink_id: str,
label: str = Form(...),
url: str = Form(...),
description: Optional[str] = Form(None),
folder_id: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("weblinks", db)
data = {
"label": label, "url": url,
"description": description if description and description.strip() else None,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
else:
data["tags"] = None
await repo.update(weblink_id, data)
# Update folder assignment
await db.execute(text("DELETE FROM folder_weblinks WHERE weblink_id = :wid"), {"wid": weblink_id})
if folder_id and folder_id.strip():
await db.execute(text("""
INSERT INTO folder_weblinks (folder_id, weblink_id)
VALUES (:fid, :wid) ON CONFLICT DO NOTHING
"""), {"fid": folder_id, "wid": weblink_id})
return RedirectResponse(url="/weblinks", status_code=303)
@router.post("/{weblink_id}/delete")
async def delete_weblink(weblink_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("weblinks", db)
await repo.soft_delete(weblink_id)
referer = request.headers.get("referer", "/weblinks")
return RedirectResponse(url=referer, status_code=303)
# ---- Folders ----
@router.get("/folders/create")
async def create_folder_form(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
result = await db.execute(text(
"SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name"
))
parent_folders = [dict(r._mapping) for r in result]
return templates.TemplateResponse("weblink_folder_form.html", {
"request": request, "sidebar": sidebar,
"parent_folders": parent_folders,
"page_title": "New Folder", "active_nav": "weblinks",
"item": None,
})
@router.post("/folders/create")
async def create_folder(
request: Request,
name: str = Form(...),
parent_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("weblink_folders", db)
data = {"name": name}
if parent_id and parent_id.strip():
data["parent_id"] = parent_id
await repo.create(data)
return RedirectResponse(url="/weblinks", status_code=303)
@router.post("/folders/{folder_id}/delete")
async def delete_folder(folder_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("weblink_folders", db)
await repo.soft_delete(folder_id)
return RedirectResponse(url="/weblinks", status_code=303)

View File

@@ -983,6 +983,36 @@ a:hover { color: var(--accent-hover); }
margin-top: 12px;
}
/* ---- Weblinks Layout ---- */
.weblinks-layout {
display: flex;
gap: 16px;
margin-top: 12px;
}
.weblinks-folders {
width: 200px;
flex-shrink: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 0;
align-self: flex-start;
}
.weblink-folder-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 0.85rem;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition);
text-decoration: none;
}
.weblink-folder-item:hover { background: var(--surface2); color: var(--text); }
.weblink-folder-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 500; }
.weblinks-content { flex: 1; min-width: 0; }
/* ---- Scrollbar ---- */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
@@ -997,3 +1027,4 @@ a:hover { color: var(--accent-hover); }
.dashboard-grid { grid-template-columns: 1fr; }
.page-content { padding: 16px; }
}
.search-type-appointments { background: var(--amber-soft); color: var(--amber); }

View File

@@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/appointments">Appointments</a>
<span class="sep">/</span>
<span>{{ appointment.title }}</span>
</div>
<div class="detail-header">
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;">
<div>
<h1 class="detail-title">{{ appointment.title }}</h1>
<div class="detail-meta">
{% if appointment.all_day %}
<span class="detail-meta-item">
<span class="status-badge status-active">All Day</span>
</span>
{% endif %}
<span class="detail-meta-item">
<svg width="14" height="14" 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>
{% if appointment.all_day %}
{{ appointment.start_at.strftime('%A, %B %-d, %Y') }}
{% if appointment.end_at and appointment.end_at.strftime('%Y-%m-%d') != appointment.start_at.strftime('%Y-%m-%d') %}
&ndash; {{ appointment.end_at.strftime('%A, %B %-d, %Y') }}
{% endif %}
{% else %}
{{ appointment.start_at.strftime('%A, %B %-d, %Y at %-I:%M %p') }}
{% if appointment.end_at %}
&ndash; {{ appointment.end_at.strftime('%-I:%M %p') }}
{% endif %}
{% endif %}
</span>
{% if appointment.location %}
<span class="detail-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ appointment.location }}
</span>
{% endif %}
{% if appointment.recurrence %}
<span class="detail-meta-item">
<span class="row-tag">{{ appointment.recurrence }}</span>
</span>
{% endif %}
</div>
</div>
<div style="display: flex; gap: 8px; flex-shrink: 0;">
<a href="/appointments/{{ appointment.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
<form method="POST" action="/appointments/{{ appointment.id }}/delete" data-confirm="Delete this appointment?">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</div>
</div>
{% if appointment.description %}
<div class="card mb-4">
<div class="card-title mb-2">Description</div>
<div class="detail-body">{{ appointment.description }}</div>
</div>
{% endif %}
{% if appointment.tags %}
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px;">
{% for tag in appointment.tags %}
<span class="row-tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
<!-- Attendees -->
<div class="card">
<div class="card-header">
<span class="card-title">Attendees ({{ contacts | length }})</span>
</div>
{% if contacts %}
{% for c in contacts %}
<div class="list-row">
<div class="row-title">
<a href="/contacts/{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</a>
</div>
{% if c.company %}
<span class="row-meta">{{ c.company }}</span>
{% endif %}
{% if c.email %}
<span class="row-meta">{{ c.email }}</span>
{% endif %}
{% if c.role %}
<span class="row-tag">{{ c.role }}</span>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-state" style="padding: 24px;">
<div class="text-muted text-sm">No attendees added</div>
</div>
{% endif %}
</div>
<div class="text-muted text-xs mt-3">
Created {{ appointment.created_at.strftime('%B %-d, %Y') }}
{% if appointment.updated_at and appointment.updated_at != appointment.created_at %}
&middot; Updated {{ appointment.updated_at.strftime('%B %-d, %Y') }}
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/appointments">Appointments</a>
<span class="sep">/</span>
<span>{{ "Edit" if appointment else "New Appointment" }}</span>
</div>
<div class="page-header">
<h1 class="page-title">{{ "Edit Appointment" if appointment else "New Appointment" }}</h1>
</div>
<div class="card" style="max-width: 720px;">
<form method="POST" action="{{ '/appointments/' ~ appointment.id ~ '/edit' if appointment else '/appointments/create' }}">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Title *</label>
<input type="text" name="title" class="form-input" required value="{{ appointment.title if appointment else '' }}">
</div>
<div class="form-group">
<label class="form-label">Start Date *</label>
<input type="date" name="start_date" class="form-input" required
value="{{ appointment.start_at.strftime('%Y-%m-%d') if appointment and appointment.start_at else '' }}">
</div>
<div class="form-group" id="start-time-group">
<label class="form-label">Start Time</label>
<input type="time" name="start_time" class="form-input" id="start-time-input"
value="{{ appointment.start_at.strftime('%H:%M') if appointment and appointment.start_at and not appointment.all_day else '' }}">
</div>
<div class="form-group">
<label class="form-label">End Date</label>
<input type="date" name="end_date" class="form-input"
value="{{ appointment.end_at.strftime('%Y-%m-%d') if appointment and appointment.end_at else '' }}">
</div>
<div class="form-group" id="end-time-group">
<label class="form-label">End Time</label>
<input type="time" name="end_time" class="form-input" id="end-time-input"
value="{{ appointment.end_at.strftime('%H:%M') if appointment and appointment.end_at and not appointment.all_day else '' }}">
</div>
<div class="form-group">
<label class="form-label" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" name="all_day" id="all-day-check"
{{ 'checked' if appointment and appointment.all_day else '' }}
onchange="toggleAllDay(this.checked)"
style="width: 16px; height: 16px;">
All Day Event
</label>
</div>
<div class="form-group">
<label class="form-label">Recurrence</label>
<select name="recurrence" class="form-select">
<option value="">None</option>
<option value="daily" {{ 'selected' if appointment and appointment.recurrence == 'daily' }}>Daily</option>
<option value="weekly" {{ 'selected' if appointment and appointment.recurrence == 'weekly' }}>Weekly</option>
<option value="monthly" {{ 'selected' if appointment and appointment.recurrence == 'monthly' }}>Monthly</option>
</select>
</div>
<div class="form-group full-width">
<label class="form-label">Location</label>
<input type="text" name="location" class="form-input" placeholder="Address, room, or video link"
value="{{ appointment.location if appointment and appointment.location else '' }}">
</div>
<div class="form-group full-width">
<label class="form-label">Description</label>
<textarea name="description" class="form-textarea" rows="3" placeholder="Notes about this appointment...">{{ appointment.description if appointment and appointment.description else '' }}</textarea>
</div>
<div class="form-group full-width">
<label class="form-label">Tags</label>
<input type="text" name="tags" class="form-input" placeholder="Comma-separated tags"
value="{{ appointment.tags | join(', ') if appointment and appointment.tags else '' }}">
</div>
{% if contacts %}
<div class="form-group full-width">
<label class="form-label">Attendees</label>
<div style="display: flex; flex-wrap: wrap; gap: 8px; padding: 8px; background: var(--surface2); border-radius: var(--radius); border: 1px solid var(--border); max-height: 200px; overflow-y: auto;">
{% for c in contacts %}
<label style="display: flex; align-items: center; gap: 6px; padding: 4px 10px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; white-space: nowrap;">
<input type="checkbox" name="contact_ids" value="{{ c.id }}"
{{ 'checked' if c.id|string in selected_contacts else '' }}
style="width: 14px; height: 14px;">
{{ c.first_name }} {{ c.last_name or '' }}
{% if c.company %}<span style="color: var(--muted); font-size: 0.78rem;">({{ c.company }})</span>{% endif %}
</label>
{% endfor %}
</div>
</div>
{% endif %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ "Update Appointment" if appointment else "Create Appointment" }}</button>
<a href="{{ '/appointments/' ~ appointment.id if appointment else '/appointments' }}" class="btn btn-secondary">Cancel</a>
</div>
</div>
</form>
</div>
<script>
function toggleAllDay(checked) {
const startTime = document.getElementById('start-time-input');
const endTime = document.getElementById('end-time-input');
if (checked) {
startTime.disabled = true;
startTime.style.opacity = '0.4';
endTime.disabled = true;
endTime.style.opacity = '0.4';
} else {
startTime.disabled = false;
startTime.style.opacity = '1';
endTime.disabled = false;
endTime.style.opacity = '1';
}
}
// Init on load
document.addEventListener('DOMContentLoaded', () => {
const cb = document.getElementById('all-day-check');
if (cb && cb.checked) toggleAllDay(true);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<div>
<h1 class="page-title">Appointments <span class="page-count">({{ count }})</span></h1>
</div>
<a href="/appointments/new" class="btn btn-primary">+ New Appointment</a>
</div>
<!-- Filters -->
<div class="filters-bar">
<a href="/appointments?timeframe=upcoming" class="btn {{ 'btn-primary' if timeframe == 'upcoming' else 'btn-secondary' }} btn-sm">Upcoming</a>
<a href="/appointments?timeframe=past" class="btn {{ 'btn-primary' if timeframe == 'past' else 'btn-secondary' }} btn-sm">Past</a>
<a href="/appointments?timeframe=all" class="btn {{ 'btn-primary' if timeframe == 'all' else 'btn-secondary' }} btn-sm">All</a>
</div>
{% if appointments %}
<div class="card">
{% set current_date = namespace(value='') %}
{% for appt in appointments %}
{% set appt_date = appt.start_at.strftime('%A, %B %-d, %Y') if appt.start_at else 'No Date' %}
{% if appt_date != current_date.value %}
{% if not loop.first %}</div>{% endif %}
<div class="date-group-label" style="padding: 12px 12px 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; {% if not loop.first %}border-top: 1px solid var(--border); margin-top: 4px;{% endif %}">
{{ appt_date }}
</div>
<div>
{% set current_date.value = appt_date %}
{% endif %}
<div class="list-row">
<div style="flex-shrink: 0; min-width: 60px;">
{% if appt.all_day %}
<span class="status-badge status-active" style="font-size: 0.72rem;">All Day</span>
{% elif appt.start_at %}
<span style="font-size: 0.85rem; font-weight: 600; color: var(--text);">{{ appt.start_at.strftime('%-I:%M %p') }}</span>
{% endif %}
</div>
<div class="row-title">
<a href="/appointments/{{ appt.id }}">{{ appt.title }}</a>
</div>
{% if appt.location %}
<span class="row-meta">{{ appt.location }}</span>
{% endif %}
{% if appt.recurrence %}
<span class="row-tag">{{ appt.recurrence }}</span>
{% endif %}
{% if appt.contact_count and appt.contact_count > 0 %}
<span class="row-meta">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
{{ appt.contact_count }}
</span>
{% endif %}
<div class="row-actions">
<a href="/appointments/{{ appt.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form method="POST" action="/appointments/{{ appt.id }}/delete" data-confirm="Delete this appointment?">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red);">Delete</button>
</form>
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📅</div>
<div class="empty-state-text">No appointments {{ 'upcoming' if timeframe == 'upcoming' else 'found' }}</div>
<a href="/appointments/new" class="btn btn-primary">Schedule an Appointment</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -52,6 +52,10 @@
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Meetings
</a>
<a href="/appointments" class="nav-item {{ 'active' if active_nav == 'appointments' }}">
<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
</a>
<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>
Decisions
@@ -60,6 +64,10 @@
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
Files
</a>
<a href="/weblinks" class="nav-item {{ 'active' if active_nav == 'weblinks' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Weblinks
</a>
<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>
Capture

View File

@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ page_title }}</h1>
</div>
<div class="card">
<form method="post" action="/weblinks/folders/create">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Folder Name *</label>
<input type="text" name="name" class="form-input" required placeholder="Folder name...">
</div>
<div class="form-group">
<label class="form-label">Parent Folder</label>
<select name="parent_id" class="form-select">
<option value="">None (top-level)</option>
{% for f in parent_folders %}
<option value="{{ f.id }}">{{ f.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Create Folder</button>
<a href="/weblinks" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ page_title }}</h1>
</div>
<div class="card">
<form method="post" action="{{ '/weblinks/' ~ item.id ~ '/edit' if item else '/weblinks/create' }}">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Label *</label>
<input type="text" name="label" class="form-input" required
value="{{ item.label if item else '' }}" placeholder="Display name...">
</div>
<div class="form-group full-width">
<label class="form-label">URL *</label>
<input type="url" name="url" class="form-input" required
value="{{ item.url if item else '' }}" placeholder="https://...">
</div>
<div class="form-group">
<label class="form-label">Folder</label>
<select name="folder_id" class="form-select">
<option value="">None</option>
{% for f in folders %}
<option value="{{ f.id }}" {{ 'selected' if prefill_folder_id == f.id|string }}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Tags</label>
<input type="text" name="tags" class="form-input" placeholder="tag1, tag2, ..."
value="{{ item.tags|join(', ') if item and item.tags else '' }}">
</div>
<div class="form-group full-width">
<label class="form-label">Description</label>
<textarea name="description" class="form-textarea" rows="2">{{ item.description if item and item.description else '' }}</textarea>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Add Weblink' }}</button>
<a href="/weblinks" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

76
templates/weblinks.html Normal file
View File

@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Weblinks<span class="page-count">{{ items|length }}</span></h1>
<div class="flex gap-2">
<a href="/weblinks/folders/create" class="btn btn-secondary">+ New Folder</a>
<a href="/weblinks/create{{ '?folder_id=' ~ current_folder_id if current_folder_id }}" class="btn btn-primary">+ New Weblink</a>
</div>
</div>
<div class="weblinks-layout">
<!-- Folder sidebar -->
<div class="weblinks-folders">
<a href="/weblinks" class="weblink-folder-item {{ 'active' if not current_folder_id }}">
All Weblinks
</a>
{% for folder in top_folders %}
<a href="/weblinks?folder_id={{ folder.id }}" class="weblink-folder-item {{ 'active' if current_folder_id == folder.id|string }}">
{{ folder.name }}
{% if folder.link_count %}<span class="badge" style="margin-left: auto;">{{ folder.link_count }}</span>{% endif %}
</a>
{% for child in child_folder_map.get(folder.id|string, []) %}
<a href="/weblinks?folder_id={{ child.id }}" class="weblink-folder-item {{ 'active' if current_folder_id == child.id|string }}" style="padding-left: 28px;">
{{ child.name }}
{% if child.link_count %}<span class="badge" style="margin-left: auto;">{{ child.link_count }}</span>{% endif %}
</a>
{% endfor %}
{% endfor %}
</div>
<!-- Weblinks list -->
<div class="weblinks-content">
{% if current_folder %}
<div class="flex items-center justify-between mb-2">
<h2 style="font-size: 1rem; font-weight: 600;">{{ current_folder.name }}</h2>
<form action="/weblinks/folders/{{ current_folder.id }}/delete" method="post"
data-confirm="Delete folder '{{ current_folder.name }}'?" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Delete Folder</button>
</form>
</div>
{% endif %}
{% if items %}
<div class="card">
{% for item in items %}
<div class="list-row">
<span class="row-title">
<a href="{{ item.url }}" target="_blank" rel="noopener">{{ item.label }}</a>
</span>
<span class="row-meta text-xs" style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ item.url }}
</span>
{% if item.tags %}
{% for tag in item.tags %}
<span class="row-tag">{{ tag }}</span>
{% endfor %}
{% endif %}
<div class="row-actions">
<a href="/weblinks/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/weblinks/{{ item.id }}/delete" method="post" data-confirm="Delete this weblink?" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">&#128279;</div>
<div class="empty-state-text">No weblinks{{ ' in this folder' if current_folder }} yet</div>
<a href="/weblinks/create{{ '?folder_id=' ~ current_folder_id if current_folder_id }}" class="btn btn-primary">Add Weblink</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}