feat: universal reorder grip handles and compact UI density

- Add generic move_in_order() to BaseRepository for reorder support
- Add reusable reorder_arrows.html partial with grip dot handles
- Add reorder routes to all 9 list routers (tasks, notes, links, contacts, meetings, decisions, appointments, lists, focus)
- Compact row padding (6px 12px, gap 8px) on .list-row and .focus-item
- Reduce font size to 0.80rem on row titles, sidebar nav, domain tree

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 01:44:30 +00:00
parent 75b055299a
commit 497436a0a3
22 changed files with 300 additions and 8 deletions

View File

@@ -248,3 +248,72 @@ class BaseRepository:
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
{"order": (i + 1) * 10, "id": str(id)}
)
async def swap_sort_order(self, id_a: str, id_b: str) -> None:
"""Swap sort_order between two rows."""
result = await self.db.execute(
text(f"SELECT id, sort_order FROM {self.table} WHERE id IN (:a, :b)"),
{"a": str(id_a), "b": str(id_b)},
)
rows = {str(r._mapping["id"]): r._mapping["sort_order"] for r in result}
if len(rows) != 2:
return
await self.db.execute(
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
{"order": rows[str(id_b)], "id": str(id_a)},
)
await self.db.execute(
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
{"order": rows[str(id_a)], "id": str(id_b)},
)
async def move_in_order(self, item_id: str, direction: str, filters: dict | None = None) -> None:
"""Move an item up or down within its sort group.
Handles lazy initialization (all sort_order=0) and swaps with neighbor.
filters: optional dict to scope the group (e.g. {"list_id": some_id}).
"""
where_clauses = ["is_deleted = false"]
params: dict[str, Any] = {}
if filters:
for i, (key, value) in enumerate(filters.items()):
if value is None:
where_clauses.append(f"{key} IS NULL")
else:
param_name = f"mf_{i}"
where_clauses.append(f"{key} = :{param_name}")
params[param_name] = value
where_sql = " AND ".join(where_clauses)
result = await self.db.execute(
text(f"SELECT id, sort_order FROM {self.table} WHERE {where_sql} ORDER BY sort_order, created_at"),
params,
)
items = [dict(r._mapping) for r in result]
if len(items) < 2:
return
# Lazy init: if all sort_order are 0, assign incremental values
if all(r["sort_order"] == 0 for r in items):
for i, r in enumerate(items):
await self.db.execute(
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
{"order": (i + 1) * 10, "id": str(r["id"])},
)
# Re-fetch
result = await self.db.execute(
text(f"SELECT id, sort_order FROM {self.table} WHERE {where_sql} ORDER BY sort_order, created_at"),
params,
)
items = [dict(r._mapping) for r in result]
ids = [str(r["id"]) for r in items]
if item_id not in ids:
return
idx = ids.index(item_id)
if direction == "up" and idx > 0:
await self.swap_sort_order(ids[idx], ids[idx - 1])
elif direction == "down" and idx < len(ids) - 1:
await self.swap_sort_order(ids[idx], ids[idx + 1])

View File

@@ -300,3 +300,15 @@ async def delete_appointment(
repo = BaseRepository("appointments", db)
await repo.soft_delete(appointment_id)
return RedirectResponse(url="/appointments", status_code=303)
@router.post("/reorder")
async def reorder_appointment(
request: Request,
item_id: str = Form(...),
direction: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("appointments", db)
await repo.move_in_order(item_id, direction)
return RedirectResponse(url=request.headers.get("referer", "/appointments"), status_code=303)

View File

@@ -124,3 +124,15 @@ async def delete_contact(contact_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("contacts", db)
await repo.soft_delete(contact_id)
return RedirectResponse(url="/contacts", status_code=303)
@router.post("/reorder")
async def reorder_contact(
request: Request,
item_id: str = Form(...),
direction: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("contacts", db)
await repo.move_in_order(item_id, direction)
return RedirectResponse(url=request.headers.get("referer", "/contacts"), status_code=303)

View File

@@ -216,3 +216,15 @@ async def delete_decision(decision_id: str, request: Request, db: AsyncSession =
repo = BaseRepository("decisions", db)
await repo.soft_delete(decision_id)
return RedirectResponse(url="/decisions", status_code=303)
@router.post("/reorder")
async def reorder_decision(
request: Request,
item_id: str = Form(...),
direction: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("decisions", db)
await repo.move_in_order(item_id, direction)
return RedirectResponse(url=request.headers.get("referer", "/decisions"), status_code=303)

View File

@@ -121,6 +121,20 @@ async def add_to_focus(
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
@router.post("/reorder")
async def reorder_focus(
request: Request,
item_id: str = Form(...),
direction: str = Form(...),
focus_date: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("daily_focus", db)
parsed_date = date.fromisoformat(focus_date)
await repo.move_in_order(item_id, direction, filters={"focus_date": parsed_date})
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
@router.post("/{focus_id}/toggle")
async def toggle_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("daily_focus", db)

View File

@@ -154,3 +154,15 @@ async def delete_link(link_id: str, request: Request, db: AsyncSession = Depends
repo = BaseRepository("links", db)
await repo.soft_delete(link_id)
return RedirectResponse(url=request.headers.get("referer", "/links"), status_code=303)
@router.post("/reorder")
async def reorder_link(
request: Request,
item_id: str = Form(...),
direction: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("links", db)
await repo.move_in_order(item_id, direction)
return RedirectResponse(url=request.headers.get("referer", "/links"), status_code=303)

View File

@@ -332,3 +332,35 @@ async def remove_contact(
"DELETE FROM contact_lists WHERE contact_id = :cid AND list_id = :lid"
), {"cid": contact_id, "lid": list_id})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/reorder")
async def reorder_list(
request: Request,
item_id: str = Form(...),
direction: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("lists", db)
await repo.move_in_order(item_id, direction)
return RedirectResponse(url=request.headers.get("referer", "/lists"), status_code=303)
@router.post("/{list_id}/items/reorder")
async def reorder_list_item(
list_id: str,
request: Request,
item_id: str = Form(...),
direction: str = Form(...),
parent_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("list_items", db)
filters = {"list_id": list_id}
if parent_id:
filters["parent_item_id"] = parent_id
else:
# Top-level items only (no parent)
filters["parent_item_id"] = None
await repo.move_in_order(item_id, direction, filters=filters)
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)

View File

@@ -404,3 +404,15 @@ async def remove_contact(
"DELETE FROM contact_meetings WHERE contact_id = :cid AND meeting_id = :mid"
), {"cid": contact_id, "mid": meeting_id})
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303)
@router.post("/reorder")
async def reorder_meeting(
request: Request,
item_id: str = Form(...),
direction: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("meetings", db)
await repo.move_in_order(item_id, direction)
return RedirectResponse(url=request.headers.get("referer", "/meetings"), status_code=303)

View File

@@ -193,3 +193,15 @@ async def delete_note(note_id: str, request: Request, db: AsyncSession = Depends
await repo.soft_delete(note_id)
referer = request.headers.get("referer", "/notes")
return RedirectResponse(url=referer, status_code=303)
@router.post("/reorder")
async def reorder_note(
request: Request,
item_id: str = Form(...),
direction: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("notes", db)
await repo.move_in_order(item_id, direction)
return RedirectResponse(url=request.headers.get("referer", "/notes"), status_code=303)

View File

@@ -481,3 +481,15 @@ async def remove_contact(
"DELETE FROM contact_tasks WHERE contact_id = :cid AND task_id = :tid"
), {"cid": contact_id, "tid": task_id})
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)
@router.post("/reorder")
async def reorder_task(
request: Request,
item_id: str = Form(...),
direction: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("tasks", db)
await repo.move_in_order(item_id, direction)
return RedirectResponse(url=request.headers.get("referer", "/tasks"), status_code=303)

View File

@@ -152,11 +152,11 @@ a:hover { color: var(--accent-hover); }
.nav-item {
display: flex;
align-items: center;
font-size: 0.80rem;
gap: 8px;
padding: 7px 10px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 0.92rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
@@ -201,7 +201,7 @@ a:hover { color: var(--accent-hover); }
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 0.85rem;
font-size: 0.80rem;
font-weight: 600;
color: var(--text);
cursor: pointer;
@@ -233,7 +233,7 @@ a:hover { color: var(--accent-hover); }
.project-link {
display: block;
padding: 4px 10px 4px 18px;
font-size: 0.85rem;
font-size: 0.80rem;
color: var(--text-secondary);
border-radius: var(--radius-sm);
white-space: nowrap;
@@ -401,8 +401,8 @@ a:hover { color: var(--accent-hover); }
.list-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
gap: 8px;
padding: 6px 12px;
border-bottom: 1px solid var(--border);
transition: background var(--transition);
}
@@ -466,6 +466,7 @@ a:hover { color: var(--accent-hover); }
.row-title {
flex: 1;
font-size: 0.80rem;
font-weight: 500;
min-width: 0;
overflow: hidden;
@@ -815,8 +816,8 @@ a:hover { color: var(--accent-hover); }
.focus-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
gap: 8px;
padding: 6px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
@@ -828,7 +829,7 @@ a:hover { color: var(--accent-hover); }
.focus-item.completed { opacity: 0.6; }
.focus-item.completed .focus-title { text-decoration: line-through; }
.focus-title { flex: 1; font-weight: 500; }
.focus-title { flex: 1; font-size: 0.80rem; font-weight: 500; }
.focus-meta { font-size: 0.78rem; color: var(--muted); }
/* ---- Alerts ---- */
@@ -1108,6 +1109,35 @@ a:hover { color: var(--accent-hover); }
transition: width 0.3s;
}
/* ---- Reorder Grip Handle ---- */
.reorder-grip {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 0;
margin-right: 4px;
opacity: 0.3;
transition: opacity 0.15s;
flex-shrink: 0;
}
.reorder-grip:hover { opacity: 0.9; }
.reorder-grip form { display: block; margin: 0; padding: 0; line-height: 0; }
.grip-btn {
display: block;
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 10px;
width: 12px;
height: 10px;
padding: 0;
line-height: 10px;
transition: color 0.15s;
text-align: center;
}
.grip-btn:hover { color: var(--accent); }
/* ---- Scrollbar ---- */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }

View File

@@ -30,6 +30,9 @@
{% endif %}
<div class="list-row">
{% with reorder_url="/appointments/reorder", item_id=appt.id %}
{% include 'partials/reorder_arrows.html' %}
{% endwith %}
<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>

View File

@@ -8,6 +8,9 @@
<div class="card">
{% for item in items %}
<div class="list-row">
{% with reorder_url="/contacts/reorder", item_id=item.id %}
{% include 'partials/reorder_arrows.html' %}
{% endwith %}
<span class="row-title"><a href="/contacts/{{ item.id }}">{{ item.first_name }} {{ item.last_name or '' }}</a></span>
{% if item.company %}<span class="row-tag">{{ item.company }}</span>{% endif %}
{% if item.role %}<span class="row-meta">{{ item.role }}</span>{% endif %}

View File

@@ -25,6 +25,9 @@
<div class="card mt-3">
{% for item in items %}
<div class="list-row">
{% with reorder_url="/decisions/reorder", item_id=item.id %}
{% include 'partials/reorder_arrows.html' %}
{% endwith %}
<span class="row-title"><a href="/decisions/{{ item.id }}">{{ item.title }}</a></span>
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
<span class="row-tag impact-{{ item.impact }}">{{ item.impact }}</span>

View File

@@ -16,6 +16,9 @@
{% if items %}
{% for item in items %}
<div class="focus-item {{ 'completed' if item.completed }}">
{% with reorder_url="/focus/reorder", item_id=item.id, extra_fields={"focus_date": focus_date|string} %}
{% include 'partials/reorder_arrows.html' %}
{% endwith %}
<form action="/focus/{{ item.id }}/toggle" method="post" style="display:inline">
<div class="row-check">
<input type="checkbox" id="f-{{ item.id }}" {{ 'checked' if item.completed }} onchange="this.form.submit()">

View File

@@ -8,6 +8,9 @@
<div class="card">
{% for item in items %}
<div class="list-row">
{% with reorder_url="/links/reorder", item_id=item.id %}
{% include 'partials/reorder_arrows.html' %}
{% endwith %}
{% if item.domain_color %}<span class="row-domain-tag" style="background:{{ item.domain_color }}22;color:{{ item.domain_color }}">{{ item.domain_name }}</span>{% endif %}
<span class="row-title"><a href="{{ item.url }}" target="_blank">{{ item.label }}</a></span>
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}

View File

@@ -46,6 +46,9 @@
<div class="card mt-2">
{% for li in list_items %}
<div class="list-row {{ 'completed' if li.completed }}">
{% with reorder_url="/lists/" ~ item.id ~ "/items/reorder", item_id=li.id %}
{% include 'partials/reorder_arrows.html' %}
{% endwith %}
{% if item.list_type == 'checklist' %}
<div class="row-check">
<form action="/lists/{{ item.id }}/items/{{ li.id }}/toggle" method="post" style="display:inline">
@@ -68,6 +71,9 @@
<!-- Child items -->
{% for child in child_map.get(li.id|string, []) %}
<div class="list-row {{ 'completed' if child.completed }}" style="padding-left: 48px;">
{% with reorder_url="/lists/" ~ item.id ~ "/items/reorder", item_id=child.id, extra_fields={"parent_id": li.id|string} %}
{% include 'partials/reorder_arrows.html' %}
{% endwith %}
{% if item.list_type == 'checklist' %}
<div class="row-check">
<form action="/lists/{{ item.id }}/items/{{ child.id }}/toggle" method="post" style="display:inline">

View File

@@ -25,6 +25,9 @@
<div class="card mt-3">
{% for item in items %}
<div class="list-row">
{% with reorder_url="/lists/reorder", item_id=item.id %}
{% include 'partials/reorder_arrows.html' %}
{% endwith %}
<span class="row-title"><a href="/lists/{{ item.id }}">{{ item.name }}</a></span>
<span class="row-meta">
{{ item.completed_count }}/{{ item.item_count }} items

View File

@@ -18,6 +18,9 @@
<div class="card mt-3">
{% for item in items %}
<div class="list-row">
{% with reorder_url="/meetings/reorder", item_id=item.id %}
{% include 'partials/reorder_arrows.html' %}
{% endwith %}
<span class="row-title"><a href="/meetings/{{ item.id }}">{{ item.title }}</a></span>
<span class="row-meta">{{ item.meeting_date }}</span>
{% if item.location %}

View File

@@ -8,6 +8,9 @@
<div class="card">
{% for item in items %}
<div class="list-row">
{% with reorder_url="/notes/reorder", item_id=item.id %}
{% include 'partials/reorder_arrows.html' %}
{% endwith %}
{% if item.domain_color %}<span class="row-domain-tag" style="background:{{ item.domain_color }}22;color:{{ item.domain_color }}">{{ item.domain_name }}</span>{% endif %}
<span class="row-title"><a href="/notes/{{ item.id }}">{{ item.title }}</a></span>
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}

View File

@@ -0,0 +1,30 @@
{#
Reorder grip handle. Include with:
{% with reorder_url="/focus/reorder", item_id=item.id %}
{% include 'partials/reorder_arrows.html' %}
{% endwith %}
Required context vars:
reorder_url - POST endpoint for the reorder action
item_id - ID of the current item
Optional:
extra_fields - dict of extra hidden fields (e.g. {"focus_date": "2026-03-03"})
#}
<span class="reorder-grip">
<form action="{{ reorder_url }}" method="post">
<input type="hidden" name="item_id" value="{{ item_id }}">
<input type="hidden" name="direction" value="up">
{% if extra_fields %}{% for k, v in extra_fields.items() %}
<input type="hidden" name="{{ k }}" value="{{ v }}">
{% endfor %}{% endif %}
<button type="submit" class="grip-btn" title="Move up"></button>
</form>
<form action="{{ reorder_url }}" method="post">
<input type="hidden" name="item_id" value="{{ item_id }}">
<input type="hidden" name="direction" value="down">
{% if extra_fields %}{% for k, v in extra_fields.items() %}
<input type="hidden" name="{{ k }}" value="{{ v }}">
{% endfor %}{% endif %}
<button type="submit" class="grip-btn" title="Move down"></button>
</form>
</span>

View File

@@ -53,6 +53,9 @@
<div class="card">
{% for item in items %}
<div class="list-row {{ 'completed' if item.status in ['done', 'cancelled'] }} {{ 'timer-active' if running_task_id and item.id|string == running_task_id }}">
{% with reorder_url="/tasks/reorder", item_id=item.id %}
{% include 'partials/reorder_arrows.html' %}
{% endwith %}
<div class="row-check">
<form action="/tasks/{{ item.id }}/toggle" method="post" style="display:inline">
<input type="checkbox" id="check-{{ item.id }}" {{ 'checked' if item.status == 'done' }}