feat: standalone focus items with edit, convert, project tab, domain ordering

- Add standalone text line items to focus (quick-add with optional domain)
- Edit page for standalone items (title, domain, project)
- Convert standalone items to task, note, link, or list item
- Focus tab on project detail page showing assigned focus items
- Sort domain groups: General first, then by domain sort_order
- Add domain_id and title to nullable_fields in BaseRepository

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 02:14:31 +00:00
parent 6aa36c570e
commit a61248b67d
6 changed files with 319 additions and 11 deletions

View File

@@ -162,6 +162,7 @@ class BaseRepository:
"contact_id", "started_at", "contact_id", "started_at",
"weekly_hours", "effective_from", "weekly_hours", "effective_from",
"task_id", "meeting_id", "list_item_id", "task_id", "meeting_id", "list_item_id",
"domain_id", "title",
} }
clean_data = {} clean_data = {}
for k, v in data.items(): for k, v in data.items():

View File

@@ -35,13 +35,13 @@ async def focus_view(
# --- All active focus items --- # --- All active focus items ---
result = await db.execute(text(""" result = await db.execute(text("""
SELECT df.*, SELECT df.*,
t.title as title, t.priority, t.status as task_status, t.title as task_title, t.priority, t.status as task_status,
t.project_id, t.due_date, t.estimated_minutes, t.project_id as task_project_id, t.due_date, t.estimated_minutes,
COALESCE(p.name, lp.name) as project_name, COALESCE(p.name, lp.name, sp.name) as project_name,
COALESCE(t.project_id, l.project_id) as effective_project_id, COALESCE(t.project_id, l.project_id, df.project_id) as effective_project_id,
COALESCE(d.name, ld.name) as domain_name, COALESCE(d.name, ld.name, sd.name) as domain_name,
COALESCE(d.color, ld.color) as domain_color, COALESCE(d.color, ld.color, sd.color) as domain_color,
COALESCE(d.id, ld.id) as effective_domain_id, COALESCE(d.id, ld.id, df.domain_id) as effective_domain_id,
COALESCE(a.name, pa.name, la.name) as area_name, COALESCE(a.name, pa.name, la.name) as area_name,
COALESCE(a.id, pa.id, la.id) as effective_area_id, COALESCE(a.id, pa.id, la.id) as effective_area_id,
li.content as list_item_content, li.list_id as list_item_list_id, li.content as list_item_content, li.list_id as list_item_list_id,
@@ -58,6 +58,8 @@ async def focus_view(
LEFT JOIN projects lp ON l.project_id = lp.id LEFT JOIN projects lp ON l.project_id = lp.id
LEFT JOIN domains ld ON l.domain_id = ld.id LEFT JOIN domains ld ON l.domain_id = ld.id
LEFT JOIN areas la ON l.area_id = la.id LEFT JOIN areas la ON l.area_id = la.id
LEFT JOIN domains sd ON df.domain_id = sd.id
LEFT JOIN projects sp ON df.project_id = sp.id
WHERE df.is_deleted = false WHERE df.is_deleted = false
AND (t.id IS NULL OR t.is_deleted = false) AND (t.id IS NULL OR t.is_deleted = false)
AND (li.id IS NULL OR li.is_deleted = false) AND (li.id IS NULL OR li.is_deleted = false)
@@ -76,7 +78,9 @@ async def focus_view(
domain_map[dk] = {"key": dk, "label": dl, "color": dc, "rows": []} domain_map[dk] = {"key": dk, "label": dl, "color": dc, "rows": []}
domain_map[dk]["rows"].append(item) domain_map[dk]["rows"].append(item)
hierarchy = list(domain_map.values()) # Sort: General first, then by domain sort_order
domain_order = {str(d["id"]): d.get("sort_order", 0) for d in await BaseRepository("domains", db).list()}
hierarchy = sorted(domain_map.values(), key=lambda g: (-1 if g["key"] == "__none__" else domain_order.get(str(g["key"]), 999)))
# --- Available tasks --- # --- Available tasks ---
available_tasks = [] available_tasks = []
@@ -207,6 +211,33 @@ async def add_to_focus(
return RedirectResponse(url="/focus", status_code=303) return RedirectResponse(url="/focus", status_code=303)
@router.post("/quick-add")
async def quick_add_focus(
request: Request,
title: str = Form(...),
quick_domain_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("daily_focus", db)
result = await db.execute(text("""
SELECT COALESCE(MAX(sort_order), 0) + 10 FROM daily_focus
WHERE is_deleted = false
"""))
next_order = result.scalar()
data = {
"focus_date": date.today(),
"sort_order": next_order,
"completed": False,
"title": title.strip(),
}
if quick_domain_id:
data["domain_id"] = quick_domain_id
await repo.create(data)
return RedirectResponse(url="/focus", status_code=303)
@router.post("/reorder") @router.post("/reorder")
async def reorder_focus( async def reorder_focus(
request: Request, request: Request,
@@ -232,6 +263,154 @@ async def reorder_all_focus(
return RedirectResponse(url="/focus", status_code=303) return RedirectResponse(url="/focus", status_code=303)
@router.get("/{focus_id}/edit")
async def edit_focus_item(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("daily_focus", db)
item = await repo.get(focus_id)
if not item or item.get("task_id") or item.get("list_item_id"):
return RedirectResponse(url="/focus", status_code=303)
sidebar = await get_sidebar_data(db)
domains = await BaseRepository("domains", db).list()
projects = await BaseRepository("projects", db).list()
lists = await BaseRepository("lists", db).list()
return templates.TemplateResponse("focus_edit.html", {
"request": request, "sidebar": sidebar, "item": item,
"domains": domains, "projects": projects, "lists": lists,
"page_title": "Edit Focus Item", "active_nav": "focus",
})
@router.post("/{focus_id}/edit")
async def update_focus_item(
focus_id: str, request: Request,
title: str = Form(...),
domain_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("daily_focus", db)
await repo.update(focus_id, {
"title": title.strip(),
"domain_id": domain_id or None,
"project_id": project_id or None,
})
return RedirectResponse(url="/focus", status_code=303)
@router.post("/{focus_id}/convert-to-task")
async def convert_focus_to_task(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("daily_focus", db)
item = await repo.get(focus_id)
if not item or not item.get("title"):
return RedirectResponse(url="/focus", status_code=303)
# Look up default domain if none set
item_domain_id = item.get("domain_id")
if not item_domain_id:
result = await db.execute(text(
"SELECT id FROM domains WHERE is_deleted = false ORDER BY sort_order LIMIT 1"
))
item_domain_id = str(result.scalar())
# Create the task
task_repo = BaseRepository("tasks", db)
task = await task_repo.create({
"title": item["title"],
"domain_id": item_domain_id,
"project_id": item.get("project_id"),
"status": "open",
"priority": 3,
})
# Update focus item to point to new task, clear standalone fields
await db.execute(text("""
UPDATE daily_focus
SET task_id = :task_id, title = NULL, domain_id = NULL, project_id = NULL,
updated_at = now()
WHERE id = :id
"""), {"task_id": task["id"], "id": focus_id})
await db.commit()
return RedirectResponse(url=f"/tasks/{task['id']}/edit", status_code=303)
@router.post("/{focus_id}/convert-to-note")
async def convert_focus_to_note(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("daily_focus", db)
item = await repo.get(focus_id)
if not item or not item.get("title"):
return RedirectResponse(url="/focus", status_code=303)
note_repo = BaseRepository("notes", db)
note = await note_repo.create({
"title": item["title"],
"domain_id": item.get("domain_id"),
"project_id": item.get("project_id"),
"body": "",
"content_format": "rich",
})
await repo.soft_delete(focus_id)
return RedirectResponse(url=f"/notes/{note['id']}/edit", status_code=303)
@router.post("/{focus_id}/convert-to-link")
async def convert_focus_to_link(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("daily_focus", db)
item = await repo.get(focus_id)
if not item or not item.get("title"):
return RedirectResponse(url="/focus", status_code=303)
link_repo = BaseRepository("links", db)
link = await link_repo.create({
"label": item["title"],
"url": "",
"domain_id": item.get("domain_id"),
"project_id": item.get("project_id"),
})
await repo.soft_delete(focus_id)
return RedirectResponse(url=f"/links/{link['id']}/edit", status_code=303)
@router.post("/{focus_id}/convert-to-list-item")
async def convert_focus_to_list_item(
focus_id: str, request: Request,
list_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("daily_focus", db)
item = await repo.get(focus_id)
if not item or not item.get("title"):
return RedirectResponse(url="/focus", status_code=303)
# Get next sort order in the list
result = await db.execute(text("""
SELECT COALESCE(MAX(sort_order), 0) + 10 FROM list_items
WHERE list_id = :list_id AND is_deleted = false
"""), {"list_id": list_id})
next_order = result.scalar()
li_repo = BaseRepository("list_items", db)
list_item = await li_repo.create({
"list_id": list_id,
"content": item["title"],
"completed": False,
"sort_order": next_order,
})
# Update focus item to point to the new list item
await db.execute(text("""
UPDATE daily_focus
SET list_item_id = :li_id, title = NULL, domain_id = NULL, project_id = NULL,
updated_at = now()
WHERE id = :id
"""), {"li_id": list_item["id"], "id": focus_id})
await db.commit()
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{focus_id}/toggle") @router.post("/{focus_id}/toggle")
async def toggle_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)): async def toggle_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("daily_focus", db) repo = BaseRepository("daily_focus", db)

View File

@@ -251,6 +251,21 @@ async def project_detail(
""")) """))
all_meetings = [dict(r._mapping) for r in result] all_meetings = [dict(r._mapping) for r in result]
elif tab == "focus":
result = await db.execute(text("""
SELECT df.*,
COALESCE(d.name, sd.name) as domain_name,
COALESCE(d.color, sd.color) as domain_color
FROM daily_focus df
LEFT JOIN domains d ON df.domain_id = d.id
LEFT JOIN domains sd ON df.domain_id = sd.id
WHERE df.is_deleted = false
AND df.title IS NOT NULL
AND df.project_id = :pid
ORDER BY df.sort_order, df.created_at
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "contacts": elif tab == "contacts":
result = await db.execute(text(""" result = await db.execute(text("""
SELECT c.*, cp.role, cp.created_at as linked_at SELECT c.*, cp.role, cp.created_at as linked_at
@@ -276,6 +291,7 @@ async def project_detail(
("decisions", "SELECT count(*) FROM decisions d JOIN decision_projects dp ON dp.decision_id = d.id WHERE dp.project_id = :pid AND d.is_deleted = false"), ("decisions", "SELECT count(*) FROM decisions d JOIN decision_projects dp ON dp.decision_id = d.id WHERE dp.project_id = :pid AND d.is_deleted = false"),
("meetings", "SELECT count(*) FROM meetings m JOIN project_meetings pm ON pm.meeting_id = m.id WHERE pm.project_id = :pid AND m.is_deleted = false"), ("meetings", "SELECT count(*) FROM meetings m JOIN project_meetings pm ON pm.meeting_id = m.id WHERE pm.project_id = :pid AND m.is_deleted = false"),
("contacts", "SELECT count(*) FROM contacts c JOIN contact_projects cp ON cp.contact_id = c.id WHERE cp.project_id = :pid AND c.is_deleted = false"), ("contacts", "SELECT count(*) FROM contacts c JOIN contact_projects cp ON cp.contact_id = c.id WHERE cp.project_id = :pid AND c.is_deleted = false"),
("focus", "SELECT count(*) FROM daily_focus WHERE project_id = :pid AND title IS NOT NULL AND is_deleted = false"),
]: ]:
result = await db.execute(text(count_sql), {"pid": project_id}) result = await db.execute(text(count_sql), {"pid": project_id})
counts[count_tab] = result.scalar() or 0 counts[count_tab] = result.scalar() or 0

View File

@@ -7,6 +7,18 @@
</div> </div>
</div> </div>
<!-- Quick add -->
<form action="/focus/quick-add" method="post" class="flex items-center gap-2 mb-3">
<input type="text" name="title" class="form-input" placeholder="Add item to focus..." required style="flex:1;padding:6px 10px;font-size:0.85rem;">
<select name="quick_domain_id" class="filter-select" style="min-width:120px;padding:6px 8px;font-size:0.82rem;">
<option value="">No Domain</option>
{% for d in domains %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary btn-sm">+ Add</button>
</form>
<!-- Focus items grouped by domain --> <!-- Focus items grouped by domain -->
{% if items %} {% if items %}
<div class="card"> <div class="card">
@@ -39,10 +51,10 @@
</form> </form>
</td> </td>
<td style="padding:1px 3px;vertical-align:middle;color:var(--muted);font-size:0.78rem;white-space:nowrap;">{{ item.due_date or '' }}</td> <td style="padding:1px 3px;vertical-align:middle;color:var(--muted);font-size:0.78rem;white-space:nowrap;">{{ item.due_date or '' }}</td>
<td style="padding:1px 1px;vertical-align:middle;">{% if item.task_id %}<span class="priority-dot priority-{{ item.priority }}"></span>{% elif item.list_item_id %}<span style="color:var(--muted);font-size:0.85rem;">&#9776;</span>{% endif %}</td> <td style="padding:1px 1px;vertical-align:middle;">{% if item.task_id %}<span class="priority-dot priority-{{ item.priority }}"></span>{% elif item.list_item_id %}<span style="color:var(--muted);font-size:0.85rem;">&#9776;</span>{% else %}<span style="color:var(--muted);font-size:0.85rem;">&#9679;</span>{% endif %}</td>
<td style="padding:1px 3px;vertical-align:middle;{{ 'text-decoration:line-through;' if item.completed }}">{% if item.task_id %}{% if item.title %}<a href="/tasks/{{ item.task_id }}" class="focus-title">{{ item.title }}</a>{% else %}<span style="color:var(--muted)">[Deleted]</span>{% endif %}{% elif item.list_item_id %}{% if item.list_item_content %}<a href="/lists/{{ item.list_item_list_id }}" class="focus-title">{{ item.list_item_content }}</a>{% else %}<span style="color:var(--muted)">[Deleted]</span>{% endif %}{% endif %}</td> <td style="padding:1px 3px;vertical-align:middle;{{ 'text-decoration:line-through;' if item.completed }}">{% if item.task_id %}{% if item.task_title %}<a href="/tasks/{{ item.task_id }}" class="focus-title">{{ item.task_title }}</a>{% else %}<span style="color:var(--muted)">[Deleted]</span>{% endif %}{% elif item.list_item_id %}{% if item.list_item_content %}<a href="/lists/{{ item.list_item_list_id }}" class="focus-title">{{ item.list_item_content }}</a>{% else %}<span style="color:var(--muted)">[Deleted]</span>{% endif %}{% else %}<a href="/focus/{{ item.id }}/edit" class="focus-title">{{ item.title }}</a>{% endif %}</td>
<td style="padding:1px 3px;vertical-align:middle;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{% if item.area_name %}<span class="row-tag">{{ item.area_name }}</span>{% endif %}</td> <td style="padding:1px 3px;vertical-align:middle;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{% if item.area_name %}<span class="row-tag">{{ item.area_name }}</span>{% endif %}</td>
<td style="padding:1px 3px;vertical-align:middle;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{% if item.task_id and item.project_name %}<span class="row-tag" style="background:var(--accent-soft);color:var(--accent)">{{ item.project_name }}</span>{% elif item.list_item_id and item.list_name %}<span class="row-tag" style="background:var(--purple);color:#fff">{{ item.list_name }}</span>{% endif %}</td> <td style="padding:1px 3px;vertical-align:middle;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{% if item.project_name %}<span class="row-tag" style="background:var(--accent-soft);color:var(--accent)">{{ item.project_name }}</span>{% elif item.list_item_id and item.list_name %}<span class="row-tag" style="background:var(--purple);color:#fff">{{ item.list_name }}</span>{% endif %}</td>
<td style="padding:1px 3px;vertical-align:middle;text-align:right;color:var(--muted);font-size:0.78rem;">{{ '~%smin'|format(item.estimated_minutes) if item.estimated_minutes else '' }}</td> <td style="padding:1px 3px;vertical-align:middle;text-align:right;color:var(--muted);font-size:0.78rem;">{{ '~%smin'|format(item.estimated_minutes) if item.estimated_minutes else '' }}</td>
<td style="padding:1px 1px;vertical-align:middle;"> <td style="padding:1px 1px;vertical-align:middle;">
<form action="/focus/{{ item.id }}/remove" method="post" style="display:inline"> <form action="/focus/{{ item.id }}/remove" method="post" style="display:inline">

85
templates/focus_edit.html Normal file
View File

@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/focus">Focus</a>
<span class="sep">/</span>
<span>Edit Item</span>
</div>
<div class="page-header">
<h1 class="page-title">Edit Focus Item</h1>
</div>
<div class="card">
<form method="post" action="/focus/{{ item.id }}/edit">
<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="{{ item.title or '' }}">
</div>
<div class="form-group">
<label class="form-label">Domain</label>
<select name="domain_id" class="form-select">
<option value="">-- No Domain --</option>
{% for d in domains %}
<option value="{{ d.id }}"
{{ 'selected' if item.domain_id and item.domain_id|string == d.id|string }}>
{{ d.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Project</label>
<select name="project_id" class="form-select">
<option value="">-- No Project --</option>
{% for p in projects %}
<option value="{{ p.id }}"
{{ 'selected' if item.project_id and item.project_id|string == p.id|string }}>
{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="/focus" class="btn btn-secondary">Cancel</a>
</div>
</div>
</form>
</div>
<div class="card" style="margin-top:16px;">
<div style="padding:16px;">
<h3 style="margin:0 0 12px;font-size:14px;color:var(--muted);">Convert to...</h3>
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;">
<form action="/focus/{{ item.id }}/convert-to-task" method="post" data-confirm="Convert to task? Opens the task editor." style="display:inline">
<button type="submit" class="btn btn-secondary btn-sm">Task</button>
</form>
<form action="/focus/{{ item.id }}/convert-to-note" method="post" data-confirm="Convert to note? Opens the note editor." style="display:inline">
<button type="submit" class="btn btn-secondary btn-sm">Note</button>
</form>
<form action="/focus/{{ item.id }}/convert-to-link" method="post" data-confirm="Convert to link? Opens the link editor." style="display:inline">
<button type="submit" class="btn btn-secondary btn-sm">Link</button>
</form>
<form action="/focus/{{ item.id }}/convert-to-list-item" method="post" data-confirm="Add to selected list?" style="display:inline-flex;gap:6px;align-items:end;">
<select name="list_id" class="form-select" style="min-width:160px;height:32px;font-size:13px;" required>
<option value="">Select list...</option>
{% for l in lists %}
<option value="{{ l.id }}">{{ l.name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-secondary btn-sm">List Item</button>
</form>
</div>
</div>
</div>
<div style="margin-top:12px;display:flex;justify-content:flex-end;">
<form action="/focus/{{ item.id }}/remove" method="post" data-confirm="Delete this focus item?" style="display:inline">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
{% endblock %}

View File

@@ -40,6 +40,7 @@
<a href="/projects/{{ item.id }}?tab=lists" class="tab-item {{ 'active' if tab == 'lists' }}">Lists{% if counts.lists %} ({{ counts.lists }}){% endif %}</a> <a href="/projects/{{ item.id }}?tab=lists" class="tab-item {{ 'active' if tab == 'lists' }}">Lists{% if counts.lists %} ({{ counts.lists }}){% endif %}</a>
<a href="/projects/{{ item.id }}?tab=decisions" class="tab-item {{ 'active' if tab == 'decisions' }}">Decisions{% if counts.decisions %} ({{ counts.decisions }}){% endif %}</a> <a href="/projects/{{ item.id }}?tab=decisions" class="tab-item {{ 'active' if tab == 'decisions' }}">Decisions{% if counts.decisions %} ({{ counts.decisions }}){% endif %}</a>
<a href="/projects/{{ item.id }}?tab=meetings" class="tab-item {{ 'active' if tab == 'meetings' }}">Meetings{% if counts.meetings %} ({{ counts.meetings }}){% endif %}</a> <a href="/projects/{{ item.id }}?tab=meetings" class="tab-item {{ 'active' if tab == 'meetings' }}">Meetings{% if counts.meetings %} ({{ counts.meetings }}){% endif %}</a>
<a href="/projects/{{ item.id }}?tab=focus" class="tab-item {{ 'active' if tab == 'focus' }}">Focus{% if counts.focus %} ({{ counts.focus }}){% endif %}</a>
<a href="/projects/{{ item.id }}?tab=processes" class="tab-item {{ 'active' if tab == 'processes' }}">Processes</a> <a href="/projects/{{ item.id }}?tab=processes" class="tab-item {{ 'active' if tab == 'processes' }}">Processes</a>
<a href="/projects/{{ item.id }}?tab=contacts" class="tab-item {{ 'active' if tab == 'contacts' }}">Contacts{% if counts.contacts %} ({{ counts.contacts }}){% endif %}</a> <a href="/projects/{{ item.id }}?tab=contacts" class="tab-item {{ 'active' if tab == 'contacts' }}">Contacts{% if counts.contacts %} ({{ counts.contacts }}){% endif %}</a>
</div> </div>
@@ -162,6 +163,20 @@
<div class="empty-state"><div class="empty-state-text">No meetings linked to this project</div></div> <div class="empty-state"><div class="empty-state-text">No meetings linked to this project</div></div>
{% endfor %} {% endfor %}
{% elif tab == 'focus' %}
{% for f in tab_data %}
<div class="list-row">
<span class="row-title"><a href="/focus/{{ f.id }}/edit">{{ f.title }}</a></span>
{% if f.domain_name %}<span class="row-tag" style="{% if f.domain_color %}border-color:{{ f.domain_color }};color:{{ f.domain_color }}{% endif %}">{{ f.domain_name }}</span>{% endif %}
<span class="row-meta">{{ f.created_at.strftime('%Y-%m-%d') if f.created_at else '' }}</span>
<div class="row-actions">
<a href="/focus/{{ f.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
</div>
</div>
{% else %}
<div class="empty-state"><div class="empty-state-text">No focus items assigned to this project</div></div>
{% endfor %}
{% elif tab == 'processes' %} {% elif tab == 'processes' %}
<div class="empty-state"><div class="empty-state-text">Process management coming soon</div></div> <div class="empty-state"><div class="empty-state-text">Process management coming soon</div></div>