From 1628a4a7489d95fac7c516f4b73277047df077bd Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 3 Mar 2026 01:45:02 +0000 Subject: [PATCH] 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 --- core/base_repository.py | 69 ++++++++++++++++++++++++++ routers/appointments.py | 12 +++++ routers/contacts.py | 12 +++++ routers/decisions.py | 12 +++++ routers/focus.py | 14 ++++++ routers/links.py | 12 +++++ routers/lists.py | 32 ++++++++++++ routers/meetings.py | 12 +++++ routers/notes.py | 12 +++++ routers/tasks.py | 12 +++++ static/style.css | 46 ++++++++++++++--- templates/appointments.html | 3 ++ templates/contacts.html | 3 ++ templates/decisions.html | 3 ++ templates/focus.html | 3 ++ templates/links.html | 3 ++ templates/list_detail.html | 6 +++ templates/lists.html | 3 ++ templates/meetings.html | 3 ++ templates/notes.html | 3 ++ templates/partials/reorder_arrows.html | 30 +++++++++++ templates/tasks.html | 3 ++ 22 files changed, 300 insertions(+), 8 deletions(-) create mode 100644 templates/partials/reorder_arrows.html diff --git a/core/base_repository.py b/core/base_repository.py index c31d698..6c6b3de 100644 --- a/core/base_repository.py +++ b/core/base_repository.py @@ -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]) diff --git a/routers/appointments.py b/routers/appointments.py index 2866b12..f4159d6 100644 --- a/routers/appointments.py +++ b/routers/appointments.py @@ -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) diff --git a/routers/contacts.py b/routers/contacts.py index cb5e66e..32d7190 100644 --- a/routers/contacts.py +++ b/routers/contacts.py @@ -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) diff --git a/routers/decisions.py b/routers/decisions.py index 1bcc907..1506180 100644 --- a/routers/decisions.py +++ b/routers/decisions.py @@ -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) diff --git a/routers/focus.py b/routers/focus.py index 2382b74..00aa374 100644 --- a/routers/focus.py +++ b/routers/focus.py @@ -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) diff --git a/routers/links.py b/routers/links.py index fadba1d..e9069bb 100644 --- a/routers/links.py +++ b/routers/links.py @@ -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) diff --git a/routers/lists.py b/routers/lists.py index 7f35306..7fdb8f4 100644 --- a/routers/lists.py +++ b/routers/lists.py @@ -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) diff --git a/routers/meetings.py b/routers/meetings.py index b0add87..0768590 100644 --- a/routers/meetings.py +++ b/routers/meetings.py @@ -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) diff --git a/routers/notes.py b/routers/notes.py index cce03a7..e7fb4e8 100644 --- a/routers/notes.py +++ b/routers/notes.py @@ -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) diff --git a/routers/tasks.py b/routers/tasks.py index cd6073b..b14ef4b 100644 --- a/routers/tasks.py +++ b/routers/tasks.py @@ -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) diff --git a/static/style.css b/static/style.css index 041edbc..b1cd4ff 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } diff --git a/templates/appointments.html b/templates/appointments.html index db2c2d7..34a7434 100644 --- a/templates/appointments.html +++ b/templates/appointments.html @@ -30,6 +30,9 @@ {% endif %}
+ {% with reorder_url="/appointments/reorder", item_id=appt.id %} + {% include 'partials/reorder_arrows.html' %} + {% endwith %}
{% if appt.all_day %} All Day diff --git a/templates/contacts.html b/templates/contacts.html index c2bd5d9..bcf89e6 100644 --- a/templates/contacts.html +++ b/templates/contacts.html @@ -8,6 +8,9 @@
{% for item in items %}
+ {% with reorder_url="/contacts/reorder", item_id=item.id %} + {% include 'partials/reorder_arrows.html' %} + {% endwith %} {{ item.first_name }} {{ item.last_name or '' }} {% if item.company %}{{ item.company }}{% endif %} {% if item.role %}{{ item.role }}{% endif %} diff --git a/templates/decisions.html b/templates/decisions.html index 2cae093..3e67bcc 100644 --- a/templates/decisions.html +++ b/templates/decisions.html @@ -25,6 +25,9 @@
{% for item in items %}
+ {% with reorder_url="/decisions/reorder", item_id=item.id %} + {% include 'partials/reorder_arrows.html' %} + {% endwith %} {{ item.title }} {{ item.status }} {{ item.impact }} diff --git a/templates/focus.html b/templates/focus.html index 5e29cbc..1d96b64 100644 --- a/templates/focus.html +++ b/templates/focus.html @@ -16,6 +16,9 @@ {% if items %} {% for item in items %}
+ {% with reorder_url="/focus/reorder", item_id=item.id, extra_fields={"focus_date": focus_date|string} %} + {% include 'partials/reorder_arrows.html' %} + {% endwith %}
diff --git a/templates/links.html b/templates/links.html index de09567..78c9e0e 100644 --- a/templates/links.html +++ b/templates/links.html @@ -8,6 +8,9 @@
{% for item in items %}
+ {% with reorder_url="/links/reorder", item_id=item.id %} + {% include 'partials/reorder_arrows.html' %} + {% endwith %} {% if item.domain_color %}{{ item.domain_name }}{% endif %} {{ item.label }} {% if item.project_name %}{{ item.project_name }}{% endif %} diff --git a/templates/list_detail.html b/templates/list_detail.html index 22ba62c..94986f3 100644 --- a/templates/list_detail.html +++ b/templates/list_detail.html @@ -46,6 +46,9 @@
{% for li in list_items %}
+ {% with reorder_url="/lists/" ~ item.id ~ "/items/reorder", item_id=li.id %} + {% include 'partials/reorder_arrows.html' %} + {% endwith %} {% if item.list_type == 'checklist' %}
@@ -68,6 +71,9 @@ {% for child in child_map.get(li.id|string, []) %}
+ {% 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' %}
diff --git a/templates/lists.html b/templates/lists.html index d36e057..12b52fc 100644 --- a/templates/lists.html +++ b/templates/lists.html @@ -25,6 +25,9 @@
{% for item in items %}
+ {% with reorder_url="/lists/reorder", item_id=item.id %} + {% include 'partials/reorder_arrows.html' %} + {% endwith %} {{ item.name }} {{ item.completed_count }}/{{ item.item_count }} items diff --git a/templates/meetings.html b/templates/meetings.html index 27839af..a8fa5a0 100644 --- a/templates/meetings.html +++ b/templates/meetings.html @@ -18,6 +18,9 @@
{% for item in items %}
+ {% with reorder_url="/meetings/reorder", item_id=item.id %} + {% include 'partials/reorder_arrows.html' %} + {% endwith %} {{ item.title }} {{ item.meeting_date }} {% if item.location %} diff --git a/templates/notes.html b/templates/notes.html index 6f94fef..8632a58 100644 --- a/templates/notes.html +++ b/templates/notes.html @@ -8,6 +8,9 @@
{% for item in items %}
+ {% with reorder_url="/notes/reorder", item_id=item.id %} + {% include 'partials/reorder_arrows.html' %} + {% endwith %} {% if item.domain_color %}{{ item.domain_name }}{% endif %} {{ item.title }} {% if item.project_name %}{{ item.project_name }}{% endif %} diff --git a/templates/partials/reorder_arrows.html b/templates/partials/reorder_arrows.html new file mode 100644 index 0000000..b451f2a --- /dev/null +++ b/templates/partials/reorder_arrows.html @@ -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"}) +#} + + + + + {% if extra_fields %}{% for k, v in extra_fields.items() %} + + {% endfor %}{% endif %} + + +
+ + + {% if extra_fields %}{% for k, v in extra_fields.items() %} + + {% endfor %}{% endif %} + +
+
diff --git a/templates/tasks.html b/templates/tasks.html index ac4f30c..3d59333 100644 --- a/templates/tasks.html +++ b/templates/tasks.html @@ -53,6 +53,9 @@
{% for item in items %}
+ {% with reorder_url="/tasks/reorder", item_id=item.id %} + {% include 'partials/reorder_arrows.html' %} + {% endwith %}