From 40a3e3642e156687897a0b1e75c963890a450861 Mon Sep 17 00:00:00 2001 From: Joerg Date: Thu, 15 Jan 2026 22:10:23 +0100 Subject: [PATCH] Add pagination to QSO Log and back buttons to pages - Add backend pagination support for /api/qsos endpoint (page, limit params) - Return paginated results with metadata (totalCount, totalPages, hasNext, hasPrev) - Add pagination UI to QSOs page with prev/next buttons and page numbers - Add back button to QSO Log and Settings pages linking to main menu - Reset to page 1 when applying filters Co-Authored-By: Claude Sonnet 4.5 --- src/backend/index.js | 12 +- src/backend/services/lotw.service.js | 49 ++++-- src/frontend/src/routes/qsos/+page.svelte | 146 +++++++++++++++++- src/frontend/src/routes/settings/+page.svelte | 28 +++- 4 files changed, 215 insertions(+), 20 deletions(-) diff --git a/src/backend/index.js b/src/backend/index.js index cd88cd8..69c796b 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -391,6 +391,7 @@ const app = new Elysia() /** * GET /api/qsos * Get user's QSOs (requires authentication) + * Supports pagination: ?page=1&limit=100 */ .get('/api/qsos', async ({ user, query, set }) => { if (!user) { @@ -404,12 +405,17 @@ const app = new Elysia() if (query.mode) filters.mode = query.mode; if (query.confirmed) filters.confirmed = query.confirmed === 'true'; - const qsos = await getUserQSOs(user.id, filters); + // Pagination options + const options = { + page: query.page ? parseInt(query.page) : 1, + limit: query.limit ? parseInt(query.limit) : 100, + }; + + const result = await getUserQSOs(user.id, filters, options); return { success: true, - qsos, - count: qsos.length, + ...result, }; } catch (error) { set.status = 500; diff --git a/src/backend/services/lotw.service.js b/src/backend/services/lotw.service.js index 4788b0b..7696e94 100644 --- a/src/backend/services/lotw.service.js +++ b/src/backend/services/lotw.service.js @@ -468,15 +468,18 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n } /** - * Get QSOs for a user + * Get QSOs for a user with pagination * @param {number} userId - User ID * @param {Object} filters - Query filters - * @returns {Promise} Array of QSOs + * @param {Object} options - Pagination options { page, limit } + * @returns {Promise} Paginated QSOs */ -export async function getUserQSOs(userId, filters = {}) { - const { eq, and } = await import('drizzle-orm'); +export async function getUserQSOs(userId, filters = {}, options = {}) { + const { eq, and, desc, sql } = await import('drizzle-orm'); - console.error('getUserQSOs called with userId:', userId, 'filters:', filters); + const { page = 1, limit = 100 } = options; + + console.error('getUserQSOs called with userId:', userId, 'filters:', filters, 'page:', page, 'limit:', limit); // Build where conditions const conditions = [eq(qsos.userId, userId)]; @@ -493,17 +496,35 @@ export async function getUserQSOs(userId, filters = {}) { conditions.push(eq(qsos.lotwQslRstatus, 'Y')); } - // Use and() to combine all conditions - const results = await db.select().from(qsos).where(and(...conditions)); + // Get total count for pagination + const allResults = await db.select().from(qsos).where(and(...conditions)); + const totalCount = allResults.length; - console.error('getUserQSOs returning', results.length, 'QSOs'); + // Calculate offset + const offset = (page - 1) * limit; - // Order by date descending, then time - return results.sort((a, b) => { - const dateCompare = b.qsoDate.localeCompare(a.qsoDate); - if (dateCompare !== 0) return dateCompare; - return b.timeOn.localeCompare(a.timeOn); - }); + // Get paginated results + const results = await db + .select() + .from(qsos) + .where(and(...conditions)) + .orderBy(desc(qsos.qsoDate), desc(qsos.timeOn)) + .limit(limit) + .offset(offset); + + console.error('getUserQSOs returning', results.length, 'QSOs (page', page, 'of', Math.ceil(totalCount / limit), ')'); + + return { + qsos: results, + pagination: { + page, + limit, + totalCount, + totalPages: Math.ceil(totalCount / limit), + hasNext: page * limit < totalCount, + hasPrev: page > 1, + }, + }; } /** diff --git a/src/frontend/src/routes/qsos/+page.svelte b/src/frontend/src/routes/qsos/+page.svelte index ccb4272..9b3c477 100644 --- a/src/frontend/src/routes/qsos/+page.svelte +++ b/src/frontend/src/routes/qsos/+page.svelte @@ -8,6 +8,11 @@ let loading = true; let error = null; + // Pagination state + let currentPage = 1; + let pageSize = 100; + let pagination = null; + // Job polling state let syncJobId = null; let syncStatus = null; @@ -51,8 +56,12 @@ if (filters.mode) activeFilters.mode = filters.mode; if (filters.confirmed) activeFilters.confirmed = 'true'; + activeFilters.page = currentPage; + activeFilters.limit = pageSize; + const response = await qsosAPI.getAll(activeFilters); qsos = response.qsos; + pagination = response.pagination; } catch (err) { error = err.message; } finally { @@ -163,10 +172,12 @@ let syncResult = null; async function applyFilters() { + currentPage = 1; await loadQSOs(); } function clearFilters() { + currentPage = 1; filters = { band: '', mode: '', @@ -175,6 +186,25 @@ loadQSOs(); } + function goToPage(page) { + currentPage = page; + loadQSOs(); + } + + function goToPrevious() { + if (pagination && pagination.hasPrev) { + currentPage--; + loadQSOs(); + } + } + + function goToNext() { + if (pagination && pagination.hasNext) { + currentPage++; + loadQSOs(); + } + } + function formatDate(dateStr) { if (!dateStr) return '-'; // ADIF format: YYYYMMDD @@ -227,7 +257,10 @@
-

QSO Log

+
+ ← Back +

QSO Log

+
{#if qsos.length > 0}
-

Showing {qsos.length} QSOs

+ + {#if pagination && pagination.totalPages > 1} + + {:else if qsos.length > 0} +

Showing {qsos.length} QSOs

+ {/if} {/if}
@@ -421,11 +497,32 @@ gap: 1rem; } + .header-left { + display: flex; + align-items: center; + gap: 1rem; + } + .header h1 { margin: 0; color: #333; } + .back-button { + padding: 0.5rem 1rem; + background-color: #6c757d; + color: white; + text-decoration: none; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; + transition: background-color 0.2s; + } + + .back-button:hover { + background-color: #5a6268; + } + .header-buttons { display: flex; gap: 1rem; @@ -669,4 +766,49 @@ font-size: 0.875rem; margin-top: 1rem; } + + .pagination { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1.5rem; + padding: 1rem; + background: #f8f9fa; + border-radius: 8px; + flex-wrap: wrap; + gap: 1rem; + } + + .pagination-info { + color: #666; + font-size: 0.9rem; + } + + .pagination-controls { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .page-numbers { + display: flex; + align-items: center; + gap: 0.25rem; + } + + .page-ellipsis { + padding: 0 0.5rem; + color: #666; + } + + .btn-small { + padding: 0.4rem 0.8rem; + font-size: 0.875rem; + min-width: 2.5rem; + } + + .btn-small:disabled { + opacity: 0.5; + cursor: not-allowed; + } diff --git a/src/frontend/src/routes/settings/+page.svelte b/src/frontend/src/routes/settings/+page.svelte index 085a36e..e3e769f 100644 --- a/src/frontend/src/routes/settings/+page.svelte +++ b/src/frontend/src/routes/settings/+page.svelte @@ -76,7 +76,10 @@
-

Settings

+
+ ← Back +

Settings

+
@@ -168,6 +171,14 @@ justify-content: space-between; align-items: center; margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; + } + + .header-left { + display: flex; + align-items: center; + gap: 1rem; } .header h1 { @@ -175,6 +186,21 @@ color: #333; } + .back-button { + padding: 0.5rem 1rem; + background-color: #6c757d; + color: white; + text-decoration: none; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; + transition: background-color 0.2s; + } + + .back-button:hover { + background-color: #5a6268; + } + .user-info { background: #f8f9fa; padding: 1.5rem;