From 8dc6ccdad064f397854cef490f2e320e2abb7af3 Mon Sep 17 00:00:00 2001 From: Louis King Date: Tue, 5 May 2026 19:28:28 +0100 Subject: [PATCH] feat: change default nodes sort to last_seen DESC and add mobile sort controls - Change API and frontend default sort from name/asc to last_seen/desc - Add mobileSortSelect() shared component for native select dropdown - Add mobile sort select to nodes, advertisements, and messages pages - Add i18n sort labels for all three list pages - Update sort tests for new default with staggered timestamps --- docs/i18n.md | 44 ++- .../20260505-1850-mobile-sorting/plan.md | 264 ++++++++++++++++++ .../20260505-1850-mobile-sorting/tasks.md | 74 +++++ src/meshcore_hub/api/routes/nodes.py | 4 +- .../web/static/js/spa/components.js | 24 ++ .../web/static/js/spa/pages/advertisements.js | 16 +- .../web/static/js/spa/pages/messages.js | 18 +- .../web/static/js/spa/pages/nodes.js | 20 +- src/meshcore_hub/web/static/locales/en.json | 36 ++- tests/test_api/test_nodes.py | 57 ++-- 10 files changed, 519 insertions(+), 38 deletions(-) create mode 100644 docs/plans/20260505-1850-mobile-sorting/plan.md create mode 100644 docs/plans/20260505-1850-mobile-sorting/tasks.md diff --git a/docs/i18n.md b/docs/i18n.md index cd389d5..6a7f723 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -182,6 +182,7 @@ Toast/flash messages after successful operations: | `snr_db` | SNR (dB) | Signal-to-noise ratio in decibels (table header) | | `unnamed` | Unnamed | Fallback for unnamed items | | `unnamed_node` | Unnamed Node | Fallback for unnamed nodes | +| `sort_by` | Sort by | Label before mobile sort dropdown | **Note:** Keys ending in `_label` have colons and are used inline. Keys without `_label` are for table headers. @@ -280,9 +281,35 @@ Node detail page labels: | `release_success` | Node released successfully | Flash message after releasing | | `release_confirm` | Are you sure you want to release this node? | Confirmation dialog for release | +#### Sort Options (`nodes.sort`) + +Mobile sort dropdown labels for the nodes list page: + +| Key | English | Context | +|-----|---------|---------| +| `sort.last_seen_newest` | Last Seen (newest) | Default sort: most recently seen first | +| `sort.last_seen_oldest` | Last Seen (oldest) | Least recently seen first | +| `sort.name_az` | Name (A–Z) | Alphabetical by display name | +| `sort.name_za` | Name (Z–A) | Reverse alphabetical by display name | +| `sort.key_asc` | Public Key (ascending) | Sorted by public key ascending | +| `sort.key_desc` | Public Key (descending) | Sorted by public key descending | + ### 10. `advertisements` -Currently empty - advertisements page uses common patterns. +Sort options for the advertisements list page: + +#### Sort Options (`advertisements.sort`) + +Mobile sort dropdown labels for the advertisements list page: + +| Key | English | Context | +|-----|---------|---------| +| `sort.newest` | Time (newest) | Default sort: most recent first | +| `sort.oldest` | Time (oldest) | Oldest first | +| `sort.node_az` | Node (A–Z) | Alphabetical by node display name | +| `sort.node_za` | Node (Z–A) | Reverse alphabetical by node display name | +| `sort.key_asc` | Public Key (ascending) | Sorted by public key ascending | +| `sort.key_desc` | Public Key (descending) | Sorted by public key descending | ### 11. `messages` @@ -295,6 +322,21 @@ Message type labels: | `type_contact` | Contact | Contact message type | | `type_public` | Public | Public message type | +#### Sort Options (`messages.sort`) + +Mobile sort dropdown labels for the messages list page: + +| Key | English | Context | +|-----|---------|---------| +| `sort.newest` | Time (newest) | Default sort: most recent first | +| `sort.oldest` | Time (oldest) | Oldest first | +| `sort.type_az` | Type (A–Z) | Alphabetical by message type | +| `sort.type_za` | Type (Z–A) | Reverse alphabetical by message type | +| `sort.from_az` | From (A–Z) | Alphabetical by sender name | +| `sort.from_za` | From (Z–A) | Reverse alphabetical by sender name | +| `sort.message_az` | Message (A–Z) | Alphabetical by message text | +| `sort.message_za` | Message (Z–A) | Reverse alphabetical by message text | + ### 12. `map` Map page content: diff --git a/docs/plans/20260505-1850-mobile-sorting/plan.md b/docs/plans/20260505-1850-mobile-sorting/plan.md new file mode 100644 index 0000000..fd5e883 --- /dev/null +++ b/docs/plans/20260505-1850-mobile-sorting/plan.md @@ -0,0 +1,264 @@ +# Plan: Default Nodes Sort by Time + Mobile Sort Controls + +**Date:** 2026-05-05 +**Branch:** `feat/mobile-sort-time-default` + +--- + +## Background + +Two issues with the current sorting UX: + +1. **Nodes default sort is alphabetical by name** (`sort=name, order=asc`). For a mesh network dashboard, sorting by most recently seen is more useful — operators want to see active nodes first. + +2. **Mobile view has no sort controls.** The desktop table view has clickable column headers, but the mobile card view (`lg:hidden`) renders a separate card layout with no sort mechanism. Mobile users cannot change the sort order at all. + +--- + +## Feature 1: Change Default Nodes Sort to Last Seen (Descending) + +### 1A. API — `src/meshcore_hub/api/routes/nodes.py` + +Change lines 205–206: + +```python +# Before +sort = sort if sort in VALID_NODE_SORT_COLUMNS else "name" +order = order if order in ("asc", "desc") else ("asc" if sort == "name" else "desc") + +# After +sort = sort if sort in VALID_NODE_SORT_COLUMNS else "last_seen" +order = order if order in ("asc", "desc") else "desc" +``` + +The default when no `sort`/`order` params are provided is now `last_seen DESC` (newest first). + +### 1B. Frontend — `src/meshcore_hub/web/static/js/spa/pages/nodes.js` + +Change lines 19–20: + +```javascript +// Before +const sort = query.sort || 'name'; +const order = query.order || 'asc'; + +// After +const sort = query.sort || 'last_seen'; +const order = query.order || 'desc'; +``` + +This ensures the "Last Seen" column header shows the correct ▾ indicator on first load. + +### 1C. Tests + +Update existing sort tests in `tests/test_api/test_nodes.py` that assume `name` default: + +- **`test_sort_by_name_default`** (line 472): Change name/docstring to `test_sort_by_last_seen_default`. Both nodes currently get identical `last_seen=datetime.now(timezone.utc)` timestamps — they must be given **staggered** timestamps (e.g., 1 hour apart) so the `last_seen DESC` default produces a deterministic order. Assert newest-first (`node_b` first if it has the later timestamp). + +- **`test_sort_invalid_ignored`** (line 643): Update docstring from "falls back to default (name alpha)" to "**falls back to default (last_seen desc)**". Both nodes currently have `last_seen=NULL` (only `first_seen` is set), so the sort order would be non-deterministic. Give the nodes staggered `last_seen` timestamps and assert the newest-first ordering. + +--- + +## Feature 2: Mobile Sort Select Dropdown + +### Design + +A compact native `` + +| Factor | Native `` already used | New pattern | New pattern | +| Accessibility | Built-in | Needs ARIA | Needs ARIA | + +### 2A. Shared component — `src/meshcore_hub/web/static/js/spa/components.js` + +Add a `mobileSortSelect()` function. It calls `buildSortUrl()` (line 18), which is a **module-scoped (non-exported) function** in the same file — accessible via closure scope, no export needed. + +```javascript +export function mobileSortSelect({ currentSort, currentOrder, navigate, basePath, params, options }) { + const currentValue = `${currentSort}:${currentOrder}`; + + const sortOptions = options.map(opt => + html`` + ); + + const onChange = (e) => { + const [sort, order] = e.target.value.split(':'); + const url = buildSortUrl(basePath, params, sort, order); + navigate(url); + }; + + return html`
+
+ ${t('common.sort_by')} + +
+
`; +} +``` + +**Parameters:** +- `currentSort` / `currentOrder`: current sort state from URL +- `navigate`: SPA router navigate function +- `basePath`: e.g., `'/nodes'` +- `params`: filter params to preserve +- `options`: array of `{ value: 'sort:order', label: 'Display Name' }` objects + +### 2B. Nodes page — `src/meshcore_hub/web/static/js/spa/pages/nodes.js` + +Add the mobile sort select between the stats row and the card list: + +```javascript +import { mobileSortSelect } from '../components.js'; + +// Define sort options +const sortOptions = [ + { value: 'last_seen:desc', label: t('nodes.sort.last_seen_newest') }, + { value: 'last_seen:asc', label: t('nodes.sort.last_seen_oldest') }, + { value: 'name:asc', label: t('nodes.sort.name_az') }, + { value: 'name:desc', label: t('nodes.sort.name_za') }, + { value: 'public_key:asc', label: t('nodes.sort.key_asc') }, + { value: 'public_key:desc', label: t('nodes.sort.key_desc') }, +]; + +// In the render, between stats badges and mobile cards: +${mobileSortSelect({ + currentSort: sort, currentOrder: order, + navigate, basePath: '/nodes', + params: headerParams, options: sortOptions, +})} +``` + +### 2C. Advertisements page — `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` + +Same pattern, with ad-specific sort options: + +```javascript +const sortOptions = [ + { value: 'time:desc', label: t('advertisements.sort.newest') }, + { value: 'time:asc', label: t('advertisements.sort.oldest') }, + { value: 'node_name:asc', label: t('advertisements.sort.node_az') }, + { value: 'node_name:desc', label: t('advertisements.sort.node_za') }, + { value: 'public_key:asc', label: t('advertisements.sort.key_asc') }, + { value: 'public_key:desc', label: t('advertisements.sort.key_desc') }, +]; +``` + +### 2D. Messages page — `src/meshcore_hub/web/static/js/spa/pages/messages.js` + +Same pattern, with message-specific sort options: + +```javascript +const sortOptions = [ + { value: 'time:desc', label: t('messages.sort.newest') }, + { value: 'time:asc', label: t('messages.sort.oldest') }, + { value: 'type:asc', label: t('messages.sort.type_az') }, + { value: 'type:desc', label: t('messages.sort.type_za') }, + { value: 'from:asc', label: t('messages.sort.from_az') }, + { value: 'from:desc', label: t('messages.sort.from_za') }, + { value: 'message:asc', label: t('messages.sort.message_az') }, + { value: 'message:desc', label: t('messages.sort.message_za') }, +]; +``` + +--- + +## i18n Changes + +### `src/meshcore_hub/web/static/locales/en.json` + +Add new keys: + +```json +{ + "common": { + "sort_by": "Sort by" + }, + "nodes": { + "sort": { + "last_seen_newest": "Last Seen (newest)", + "last_seen_oldest": "Last Seen (oldest)", + "name_az": "Name (A\u2013Z)", + "name_za": "Name (Z\u2013A)", + "key_asc": "Public Key (ascending)", + "key_desc": "Public Key (descending)" + } + }, + "advertisements": { + "sort": { + "newest": "Time (newest)", + "oldest": "Time (oldest)", + "node_az": "Node (A\u2013Z)", + "node_za": "Node (Z\u2013A)", + "key_asc": "Public Key (ascending)", + "key_desc": "Public Key (descending)" + } + }, + "messages": { + "sort": { + "newest": "Time (newest)", + "oldest": "Time (oldest)", + "type_az": "Type (A\u2013Z)", + "type_za": "Type (Z\u2013A)", + "from_az": "From (A\u2013Z)", + "from_za": "From (Z\u2013A)", + "message_az": "Message (A\u2013Z)", + "message_za": "Message (Z\u2013A)" + } + } +} +``` + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `src/meshcore_hub/api/routes/nodes.py` | Change default sort from `name ASC` to `last_seen DESC` | +| `src/meshcore_hub/web/static/js/spa/pages/nodes.js` | Change frontend default sort; add mobile sort select | +| `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` | Add mobile sort select | +| `src/meshcore_hub/web/static/js/spa/pages/messages.js` | Add mobile sort select | +| `src/meshcore_hub/web/static/js/spa/components.js` | Add `mobileSortSelect()` export | +| `src/meshcore_hub/web/static/locales/en.json` | Add sort option translation keys | +| `docs/i18n.md` | Document new translation keys | +| `tests/test_api/test_nodes.py` | Update default sort test expectations | + +--- + +## Sequence + +1. Change default sort in API (`nodes.py`) +2. Change frontend default in `nodes.js` +3. Add `mobileSortSelect()` to `components.js` +4. Add mobile sort select to `nodes.js` +5. Add mobile sort select to `advertisements.js` +6. Add mobile sort select to `messages.js` +7. Add i18n keys to `en.json` and update `docs/i18n.md` +8. Update API tests +9. Run `pytest tests/test_api/` +10. Run `pre-commit run --all-files` diff --git a/docs/plans/20260505-1850-mobile-sorting/tasks.md b/docs/plans/20260505-1850-mobile-sorting/tasks.md new file mode 100644 index 0000000..433fc8c --- /dev/null +++ b/docs/plans/20260505-1850-mobile-sorting/tasks.md @@ -0,0 +1,74 @@ +# Tasks: Default Nodes Sort by Time + Mobile Sort Controls + +**Plan:** [plan.md](plan.md) +**Branch:** `feat/mobile-sort-time-default` + +--- + +## Phase 1: Change Default Nodes Sort to Last Seen (Descending) + +- [ ] **1.1** Update API default sort in `src/meshcore_hub/api/routes/nodes.py` (lines 205–206) + - Change `else "name"` → `else "last_seen"` + - Change `else ("asc" if sort == "name" else "desc")` → `else "desc"` + +- [ ] **1.2** Update frontend default sort in `src/meshcore_hub/web/static/js/spa/pages/nodes.js` (lines 19–20) + - Change `query.sort || 'name'` → `query.sort || 'last_seen'` + - Change `query.order || 'asc'` → `query.order || 'desc'` + +- [ ] **1.3** Update existing tests in `tests/test_api/test_nodes.py` + - **`test_sort_by_name_default`** (line 472): Rename to `test_sort_by_last_seen_default` + - Give the two test nodes **staggered** `last_seen` timestamps (e.g., 1 hour apart) instead of identical `datetime.now(timezone.utc)` + - Assert newest-first order (`node_b` first if it has the later timestamp) + - **`test_sort_invalid_ignored`** (line 643): Update docstring to "falls back to default (last_seen desc)" + - Give the two test nodes staggered `last_seen` timestamps and assert newest-first ordering + +## Phase 2: Shared Mobile Sort Component + +- [ ] **2.1** Add `mobileSortSelect()` export in `src/meshcore_hub/web/static/js/spa/components.js` + - Parameters: `{ currentSort, currentOrder, navigate, basePath, params, options }` + - Renders `lg:hidden` wrapper with label + native ` + ${sortOptions} + + + `; +} + /** * Get app config from the embedded window object. * @returns {Object} App configuration diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index 043221b..4cd4455 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -3,7 +3,7 @@ import { html, litRender, nothing, t, getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime, warningBadge, - pagination, sortableTableHeader, + pagination, sortableTableHeader, mobileSortSelect, renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail } from '../components.js'; @@ -239,6 +239,20 @@ ${displayContent}`, container); renderPage(html`${filterCard} +${mobileSortSelect({ + currentSort: sort, currentOrder: order, + navigate, basePath: '/advertisements', + params: headerParams, + options: [ + { value: 'time:desc', label: t('advertisements.sort.newest') }, + { value: 'time:asc', label: t('advertisements.sort.oldest') }, + { value: 'node_name:asc', label: t('advertisements.sort.node_az') }, + { value: 'node_name:desc', label: t('advertisements.sort.node_za') }, + { value: 'public_key:asc', label: t('advertisements.sort.key_asc') }, + { value: 'public_key:desc', label: t('advertisements.sort.key_desc') }, + ], +})} +
${mobileCards}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js index 1fd3baa..4c62bb4 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/messages.js +++ b/src/meshcore_hub/web/static/js/spa/pages/messages.js @@ -4,7 +4,7 @@ import { getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime, getChannelLabelsMap, resolveChannelLabel, truncateKey, warningBadge, - pagination, sortableTableHeader, timezoneIndicator, + pagination, sortableTableHeader, mobileSortSelect, timezoneIndicator, renderFilterCard, autoSubmit, submitOnEnter, observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail } from '../components.js'; @@ -386,6 +386,22 @@ ${displayContent}`, container); renderPage(html`${filterCard} +${mobileSortSelect({ + currentSort: sort, currentOrder: order, + navigate, basePath: '/messages', + params: headerParams, + options: [ + { value: 'time:desc', label: t('messages.sort.newest') }, + { value: 'time:asc', label: t('messages.sort.oldest') }, + { value: 'type:asc', label: t('messages.sort.type_az') }, + { value: 'type:desc', label: t('messages.sort.type_za') }, + { value: 'from:asc', label: t('messages.sort.from_az') }, + { value: 'from:desc', label: t('messages.sort.from_za') }, + { value: 'message:asc', label: t('messages.sort.message_az') }, + { value: 'message:desc', label: t('messages.sort.message_za') }, + ], +})} +
${mobileCards}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/nodes.js b/src/meshcore_hub/web/static/js/spa/pages/nodes.js index 0c42f3a..341cd88 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/nodes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/nodes.js @@ -3,7 +3,7 @@ import { html, litRender, nothing, getConfig, formatDateTime, formatDateTimeShort, warningBadge, - pagination, sortableTableHeader, + pagination, sortableTableHeader, mobileSortSelect, renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t } from '../components.js'; import { createAutoRefresh } from '../auto-refresh.js'; @@ -16,8 +16,8 @@ export async function render(container, params, router) { const page = parseInt(query.page, 10) || 1; const limit = parseInt(query.limit, 10) || 20; const offset = (page - 1) * limit; - const sort = query.sort || 'name'; - const order = query.order || 'asc'; + const sort = query.sort || 'last_seen'; + const order = query.order || 'desc'; const config = getConfig(); const tz = config.timezone || ''; @@ -186,6 +186,20 @@ ${displayContent}`, container); renderPage(html`${filterCard} +${mobileSortSelect({ + currentSort: sort, currentOrder: order, + navigate, basePath: '/nodes', + params: headerParams, + options: [ + { value: 'last_seen:desc', label: t('nodes.sort.last_seen_newest') }, + { value: 'last_seen:asc', label: t('nodes.sort.last_seen_oldest') }, + { value: 'name:asc', label: t('nodes.sort.name_az') }, + { value: 'name:desc', label: t('nodes.sort.name_za') }, + { value: 'public_key:asc', label: t('nodes.sort.key_asc') }, + { value: 'public_key:desc', label: t('nodes.sort.key_desc') }, + ], +})} +
${mobileCards}
diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index 05a905d..eb4543d 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -105,7 +105,8 @@ "unnamed": "Unnamed", "unnamed_node": "Unnamed Node", "validation_invalid_number": "Value must be a valid number", - "validation_invalid_boolean": "Value must be true, false, yes, no, 1, or 0" + "validation_invalid_boolean": "Value must be true, false, yes, no, 1, or 0", + "sort_by": "Sort by" }, "links": { "website": "Website", @@ -162,14 +163,41 @@ "not_adopted": "This node has not been adopted by any operator.", "adopt_success": "Node adopted successfully", "release_success": "Node released successfully", - "release_confirm": "Are you sure you want to release this node?" + "release_confirm": "Are you sure you want to release this node?", + "sort": { + "last_seen_newest": "Last Seen (newest)", + "last_seen_oldest": "Last Seen (oldest)", + "name_az": "Name (A\u2013Z)", + "name_za": "Name (Z\u2013A)", + "key_asc": "Public Key (ascending)", + "key_desc": "Public Key (descending)" + } + }, + "advertisements": { + "sort": { + "newest": "Time (newest)", + "oldest": "Time (oldest)", + "node_az": "Node (A\u2013Z)", + "node_za": "Node (Z\u2013A)", + "key_asc": "Public Key (ascending)", + "key_desc": "Public Key (descending)" + } }, - "advertisements": {}, "messages": { "type_direct": "Direct", "type_channel": "Channel", "type_contact": "Contact", - "type_public": "Public" + "type_public": "Public", + "sort": { + "newest": "Time (newest)", + "oldest": "Time (oldest)", + "type_az": "Type (A\u2013Z)", + "type_za": "Type (Z\u2013A)", + "from_az": "From (A\u2013Z)", + "from_za": "From (Z\u2013A)", + "message_az": "Message (A\u2013Z)", + "message_za": "Message (Z\u2013A)" + } }, "map": { "show_labels": "Show Labels", diff --git a/tests/test_api/test_nodes.py b/tests/test_api/test_nodes.py index 6d7580f..e9cf304 100644 --- a/tests/test_api/test_nodes.py +++ b/tests/test_api/test_nodes.py @@ -469,35 +469,36 @@ class TestNodeTags: class TestNodeSort: """Tests for node list sort parameters.""" - def test_sort_by_name_default(self, client_no_auth, api_db_session): - """Default sort (no params) returns nodes alpha by display name.""" - from datetime import datetime, timezone + def test_sort_by_last_seen_default(self, client_no_auth, api_db_session): + """Default sort (no params) returns nodes by last_seen descending.""" + from datetime import datetime, timezone, timedelta from meshcore_hub.common.models import Node - node_b = Node( - public_key="bb" * 32, - name="Bravo", - adv_type="CLIENT", - first_seen=datetime.now(timezone.utc), - last_seen=datetime.now(timezone.utc), - ) + now = datetime.now(timezone.utc) node_a = Node( public_key="aa" * 32, name="Alpha", adv_type="CLIENT", - first_seen=datetime.now(timezone.utc), - last_seen=datetime.now(timezone.utc), + first_seen=now, + last_seen=now, ) - api_db_session.add_all([node_b, node_a]) + node_b = Node( + public_key="bb" * 32, + name="Bravo", + adv_type="CLIENT", + first_seen=now, + last_seen=now + timedelta(hours=1), + ) + api_db_session.add_all([node_a, node_b]) api_db_session.commit() response = client_no_auth.get("/api/v1/nodes") assert response.status_code == 200 items = response.json()["items"] assert len(items) == 2 - assert items[0]["name"] == "Alpha" - assert items[1]["name"] == "Bravo" + assert items[0]["name"] == "Bravo" + assert items[1]["name"] == "Alpha" def test_sort_by_name_asc(self, client_no_auth, api_db_session): """Explicit sort=name&order=asc.""" @@ -641,30 +642,34 @@ class TestNodeSort: assert items[1]["name"] == "Alpha" def test_sort_invalid_ignored(self, client_no_auth, api_db_session): - """Invalid sort value falls back to default (name alpha).""" - from datetime import datetime, timezone + """Invalid sort value falls back to default (last_seen desc).""" + from datetime import datetime, timezone, timedelta from meshcore_hub.common.models import Node - node_b = Node( - public_key="bb" * 32, - name="Bravo", - adv_type="CLIENT", - first_seen=datetime.now(timezone.utc), - ) + now = datetime.now(timezone.utc) node_a = Node( public_key="aa" * 32, name="Alpha", adv_type="CLIENT", - first_seen=datetime.now(timezone.utc), + first_seen=now, + last_seen=now, ) - api_db_session.add_all([node_b, node_a]) + node_b = Node( + public_key="bb" * 32, + name="Bravo", + adv_type="CLIENT", + first_seen=now, + last_seen=now + timedelta(hours=1), + ) + api_db_session.add_all([node_a, node_b]) api_db_session.commit() response = client_no_auth.get("/api/v1/nodes?sort=invalid_column") assert response.status_code == 200 items = response.json()["items"] - assert items[0]["name"] == "Alpha" + assert items[0]["name"] == "Bravo" + assert items[1]["name"] == "Alpha" def test_sort_nodes_with_null_name(self, client_no_auth, api_db_session): """Nodes with name=NULL sort by public_key via COALESCE fallback."""