From 1aa92106747fe7b019d4a0ea413f13f550dad85e Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 22 Apr 2026 01:58:55 +0900 Subject: [PATCH] fixed issues around gateways --- messages/en.json | 20 ++-- messages/ja.json | 20 ++-- src/routes/Sidebar.svelte | 2 +- src/routes/gateways/+page.svelte | 53 ++--------- src/routes/gateways/gateway-table.spec.ts | 68 ++++++++++++++ src/routes/gateways/gateway-table.ts | 109 ++++++++++++++++++++++ 6 files changed, 208 insertions(+), 64 deletions(-) create mode 100644 src/routes/gateways/gateway-table.spec.ts create mode 100644 src/routes/gateways/gateway-table.ts diff --git a/messages/en.json b/messages/en.json index a72c1f74..eb8285c3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5,7 +5,7 @@ "common_name": "Name", "common_description": "Description", "common_group": "Group", - "common_created": "Created", + "common_created": "Updated At", "common_actions": "Actions", "common_all_short": "ALL", "common_not_available": "N/A", @@ -42,8 +42,6 @@ "nav_billing": "Billing", "nav_settings": "Settings", "nav_logout": "Logout", - "nav_group_info_management": "Info and Management", - "nav_group_connectivity_hardware": "Connectivity & Hardware", "header_privacy_mode": "Privacy Mode", "header_logout_error": "An error occurred while logging out. Please try again.", "status_online": "Online", @@ -383,8 +381,6 @@ "reports_create_schedule_active_description": "Turn this delivery schedule on.", "reports_create_schedule_week_label": "Weekly", "reports_create_schedule_week_description": "Send this report each week.", - "reports_create_schedule_day_label": "Daily", - "reports_create_schedule_day_description": "Send this report each day.", "reports_create_schedule_month_label": "Month-end", "reports_create_schedule_month_description": "Send this report at the end of the month.", "reports_create_schedule_child_device_label": "Device ID", @@ -673,11 +669,6 @@ "devices_export_select_range": "Select export range", "devices_export_success": "Exported {count} telemetry rows.", "devices_export_timezone_hint": "Request and export timestamps use {timeZone}.", - "devices_export_traffic_button": "Export Traffic", - "devices_export_traffic_dialog_title": "Export Traffic Data", - "devices_export_traffic_invalid_input": "Please select a valid year and month.", - "devices_export_traffic_label_month": "Month", - "devices_export_traffic_label_year": "Year", "devices_group_placeholder": "Optional device group", "devices_identity_section_subtitle": "Core cw_device fields used for the initial POST payload.", "devices_identity_section_title": "Device Identity", @@ -893,6 +884,15 @@ "stat_aboveAvg": "Above Avg", "stat_belowAvg": "Below Avg", "stat_atAvg": "At Avg", + "nav_group_info_management": "Info and Management", + "nav_group_connectivity_hardware": "Connectivity & Hardware", + "reports_create_schedule_day_label": "Daily", + "reports_create_schedule_day_description": "Send this report each day.", + "devices_export_traffic_button": "Export Traffic", + "devices_export_traffic_dialog_title": "Export Traffic Data", + "devices_export_traffic_invalid_input": "Please select a valid year and month.", + "devices_export_traffic_label_month": "Month", + "devices_export_traffic_label_year": "Year", "stat_expand": "Expand", "stat_collapse": "Collapse", "traffic_metric_bicycle_count": "Bicycle Count", diff --git a/messages/ja.json b/messages/ja.json index d9f664ca..d5786ea2 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -5,7 +5,7 @@ "common_name": "名前", "common_description": "説明", "common_group": "グループ", - "common_created": "作成日時", + "common_created": "最終更新日時", "common_actions": "操作", "common_all_short": "ALL", "common_not_available": "N/A", @@ -42,8 +42,6 @@ "nav_billing": "請求", "nav_settings": "設定", "nav_logout": "ログアウト", - "nav_group_info_management": "情報と管理", - "nav_group_connectivity_hardware": "接続とハードウェア", "header_privacy_mode": "プライバシーモード", "header_logout_error": "ログアウト中にエラーが発生しました。もう一度お試しください。", "status_online": "オンライン", @@ -383,8 +381,6 @@ "reports_create_schedule_active_description": "この配信スケジュールを有効にします。", "reports_create_schedule_week_label": "毎週", "reports_create_schedule_week_description": "このレポートを毎週送信します。", - "reports_create_schedule_day_label": "毎日", - "reports_create_schedule_day_description": "このレポートを毎日送信します。", "reports_create_schedule_month_label": "月末", "reports_create_schedule_month_description": "このレポートを月末に送信します。", "reports_create_schedule_child_device_label": "デバイス ID", @@ -673,11 +669,6 @@ "devices_export_select_range": "エクスポート範囲を選択", "devices_export_success": "テレメトリ {count} 行をエクスポートしました。", "devices_export_timezone_hint": "リクエストとエクスポートのタイムスタンプには {timeZone} を使用します。", - "devices_export_traffic_button": "トラフィックをエクスポート", - "devices_export_traffic_dialog_title": "トラフィックデータのエクスポート", - "devices_export_traffic_invalid_input": "有効な年と月を選択してください。", - "devices_export_traffic_label_month": "月", - "devices_export_traffic_label_year": "年", "devices_group_placeholder": "任意のデバイスグループ", "devices_identity_section_subtitle": "初回 POST ペイロードで使用する主要な cw_device フィールドです。", "devices_identity_section_title": "デバイス識別情報", @@ -895,6 +886,15 @@ "stat_atAvg": "平均", "stat_expand": "展開", "stat_collapse": "折りたたむ", + "nav_group_info_management": "情報と管理", + "nav_group_connectivity_hardware": "接続とハードウェア", + "reports_create_schedule_day_label": "毎日", + "reports_create_schedule_day_description": "このレポートを毎日送信します。", + "devices_export_traffic_button": "トラフィックをエクスポート", + "devices_export_traffic_dialog_title": "トラフィックデータのエクスポート", + "devices_export_traffic_invalid_input": "有効な年と月を選択してください。", + "devices_export_traffic_label_month": "月", + "devices_export_traffic_label_year": "年", "traffic_metric_bicycle_count": "自転車台数", "traffic_metric_bus_count": "バス台数", "traffic_metric_car_count": "車台数", diff --git a/src/routes/Sidebar.svelte b/src/routes/Sidebar.svelte index 69d228fa..a82be489 100644 --- a/src/routes/Sidebar.svelte +++ b/src/routes/Sidebar.svelte @@ -29,7 +29,7 @@ const REPORTS_ICON_PATH = 'M4 2.5h5l3 3V13a1 1 0 01-1 1H4a1 1 0 01-1-1v-9a1 1 0 011-1zM9 2.5V5a1 1 0 001 1h2M5.5 8.5h5M5.5 10.5h5'; const GATEWAYS_ICON_PATH = - 'M3 4a1 1 0 011-1h1v2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h1v2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h1v2H4a1 1 0 01-1-1zm5-10a1 1 0 011-1h1v2H9a1 1 0 01-1-1zm0 5a1 1 0 011-1h1v2H9a1 1 0 01-1-1zm0 5a1 1 0 011-1h1v2H9a1 1 0 01-1-1z'; + 'M3 8.5h10A1.5 1.5 0 0 1 14.5 10v2A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12v-2A1.5 1.5 0 0 1 3 8.5Zm0 2.5h.01M5.5 11h.01M8 11h.01M8 8.5V6M6 4.5a3.2 3.2 0 0 1 4 0M4.5 3a5.5 5.5 0 0 1 7 0'; // ── Read active filters from URL search params ────────────── let selectedGroup = $derived(page.url.searchParams.get('group') ?? ''); let selectedLocation = $derived(page.url.searchParams.get('location') ?? ''); diff --git a/src/routes/gateways/+page.svelte b/src/routes/gateways/+page.svelte index a78ca2aa..68cdc749 100644 --- a/src/routes/gateways/+page.svelte +++ b/src/routes/gateways/+page.svelte @@ -13,61 +13,28 @@ import { resolve } from '$app/paths'; import { m } from '$lib/paraglide/messages.js'; import { getAppContext } from '$lib/appContext.svelte'; - import { ApiService, type GatewayDto } from '$lib/api/api.service'; + import { ApiService } from '$lib/api/api.service'; import { formatDateTime } from '$lib/i18n/format'; import CHECK_ICON from '$lib/images/icons/check_circle.svg'; import NO_ICON from '$lib/images/icons/no.svg'; + import { buildGatewayTableResult, type GatewayTableRow } from './gateway-table'; let loading = $state(false); let app = getAppContext(); - const columns: CwColumnDef[] = [ + const columns: CwColumnDef[] = [ { key: 'gateway_name', header: m.gateways_gateway_name(), sortable: true }, { key: 'gateway_id', header: m.gateways_gateway_id() }, { key: 'is_online', header: m.gateways_status(), sortable: true }, { key: 'is_public', header: m.gateways_public(), sortable: true }, - { key: 'created_at', header: m.common_created(), sortable: true } + { key: 'updated_at', header: m.common_created(), sortable: true } ]; - function sortGateways( - rows: GatewayDto[], - column: string, - direction: 'asc' | 'desc' - ): GatewayDto[] { - const dir = direction === 'asc' ? 1 : -1; - return [...rows].sort((a, b) => { - const aVal = (a as unknown as Record)[column]; - const bVal = (b as unknown as Record)[column]; - if (aVal == null && bVal == null) return 0; - if (aVal == null) return dir; - if (bVal == null) return -dir; - if (typeof aVal === 'boolean') return (Number(aVal) - Number(bVal)) * dir; - return String(aVal).localeCompare(String(bVal)) * dir; - }); - } - - async function loadData(query: CwTableQuery): Promise> { - const search = query.search?.trim().toLowerCase() || ''; + async function loadData(query: CwTableQuery): Promise> { const api = new ApiService({ authToken: app.accessToken }); const gateways = await api.getGateways(); - let rows = search - ? gateways.filter( - (gw) => - gw.gateway_name.toLowerCase().includes(search) || - gw.gateway_id.toLowerCase().includes(search) - ) - : gateways; - - if (query.sort) { - rows = sortGateways(rows, query.sort.column, query.sort.direction); - } - - const total = rows.length; - const skip = (query.page - 1) * query.pageSize; - rows = rows.slice(skip, skip + query.pageSize); - - return { rows, total }; + return buildGatewayTableResult(gateways, query); } @@ -81,8 +48,8 @@ - - {#snippet cell(row: GatewayDto, col: CwColumnDef, defaultValue: string)} + + {#snippet cell(row: GatewayTableRow, col: CwColumnDef, defaultValue: string)} {#if col.key === 'is_online'} {#if row.is_online} @@ -99,8 +66,8 @@ {/if} - {:else if col.key === 'created_at'} - {formatDateTime(row.created_at, undefined, m.common_not_available())} + {:else if col.key === 'updated_at'} + {formatDateTime(row.updated_at, undefined, m.common_not_available())} {:else} {defaultValue} {/if} diff --git a/src/routes/gateways/gateway-table.spec.ts b/src/routes/gateways/gateway-table.spec.ts new file mode 100644 index 00000000..f8af519f --- /dev/null +++ b/src/routes/gateways/gateway-table.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import type { CwTableQuery } from '@cropwatchdevelopment/cwui'; +import type { GatewayDto } from '$lib/api/api.dtos'; +import { buildGatewayTableResult, buildGatewayTableRows } from './gateway-table'; + +function createQuery(overrides: Partial = {}): CwTableQuery { + return { + page: 1, + pageSize: 25, + search: '', + sort: null, + filters: {}, + signal: new AbortController().signal, + ...overrides + }; +} + +function gateway(overrides: Partial): GatewayDto { + return { + id: 1, + gateway_id: 'gw-1', + gateway_name: 'North Gateway', + is_online: true, + is_public: false, + created_at: '2026-04-22T00:00:00.000Z', + updated_at: null, + ...overrides + }; +} + +describe('gateway table helpers', () => { + it('builds unique table row keys even when API ids are duplicated or missing', () => { + const rows = buildGatewayTableRows([ + gateway({ id: 7, gateway_id: 'shared' }), + gateway({ id: 7, gateway_id: 'shared' }), + gateway({ id: undefined as unknown as number, gateway_id: '' }), + gateway({ id: undefined as unknown as number, gateway_id: '' }) + ]); + + expect(rows.map((row) => row.tableRowKey)).toEqual([ + 'gateway:shared:0', + 'gateway:shared:1', + 'gateway:row-2:0', + 'gateway:row-3:0' + ]); + expect(new Set(rows.map((row) => row.tableRowKey)).size).toBe(rows.length); + }); + + it('filters, sorts, and paginates while preserving unique row keys', () => { + const result = buildGatewayTableResult( + [ + gateway({ gateway_id: 'gw-2', gateway_name: 'Beta', is_online: false }), + gateway({ gateway_id: 'gw-1', gateway_name: 'Alpha', is_online: true }), + gateway({ gateway_id: 'gw-3', gateway_name: 'Alpine', is_online: true }) + ], + createQuery({ + page: 1, + pageSize: 2, + search: 'Al', + sort: { column: 'gateway_name', direction: 'desc' } + }) + ); + + expect(result.total).toBe(2); + expect(result.rows.map((row) => row.gateway_name)).toEqual(['Alpine', 'Alpha']); + expect(new Set(result.rows.map((row) => row.tableRowKey)).size).toBe(result.rows.length); + }); +}); diff --git a/src/routes/gateways/gateway-table.ts b/src/routes/gateways/gateway-table.ts new file mode 100644 index 00000000..35403df9 --- /dev/null +++ b/src/routes/gateways/gateway-table.ts @@ -0,0 +1,109 @@ +import type { CwTableQuery, CwTableResult } from '@cropwatchdevelopment/cwui'; +import type { GatewayDto } from '$lib/api/api.dtos'; + +export interface GatewayTableRow extends GatewayDto { + tableRowKey: string; +} + +function getGatewayText(value: unknown): string { + if (typeof value === 'string') { + return value; + } + + if (value == null) { + return ''; + } + + return String(value); +} + +function getGatewayRowBaseKey(gateway: GatewayDto, index: number): string { + const gatewayId = getGatewayText(gateway.gateway_id).trim(); + if (gatewayId) { + return gatewayId; + } + + const id = getGatewayText(gateway.id).trim(); + return id || `row-${index}`; +} + +export function buildGatewayTableRows(gateways: GatewayDto[]): GatewayTableRow[] { + const keyCounts = new Map(); + + return gateways.map((gateway, index) => { + const baseKey = getGatewayRowBaseKey(gateway, index); + const duplicateIndex = keyCounts.get(baseKey) ?? 0; + keyCounts.set(baseKey, duplicateIndex + 1); + + return { + ...gateway, + gateway_id: getGatewayText(gateway.gateway_id), + gateway_name: getGatewayText(gateway.gateway_name), + tableRowKey: `gateway:${baseKey}:${duplicateIndex}` + }; + }); +} + +export function filterGatewayRows(rows: GatewayTableRow[], search: string): GatewayTableRow[] { + const normalizedSearch = search.trim().toLowerCase(); + if (!normalizedSearch) { + return rows; + } + + return rows.filter( + (row) => + row.gateway_name.toLowerCase().includes(normalizedSearch) || + row.gateway_id.toLowerCase().includes(normalizedSearch) + ); +} + +export function sortGatewayRows( + rows: GatewayTableRow[], + column: string, + direction: 'asc' | 'desc' +): GatewayTableRow[] { + const dir = direction === 'asc' ? 1 : -1; + + return [...rows].sort((left, right) => { + const leftValue = (left as unknown as Record)[column]; + const rightValue = (right as unknown as Record)[column]; + + if (leftValue == null && rightValue == null) return 0; + if (leftValue == null) return dir; + if (rightValue == null) return -dir; + + if (typeof leftValue === 'boolean' && typeof rightValue === 'boolean') { + return (Number(leftValue) - Number(rightValue)) * dir; + } + + if (typeof leftValue === 'number' && typeof rightValue === 'number') { + return (leftValue - rightValue) * dir; + } + + return ( + getGatewayText(leftValue).localeCompare(getGatewayText(rightValue), undefined, { + numeric: true, + sensitivity: 'base' + }) * dir + ); + }); +} + +export function buildGatewayTableResult( + gateways: GatewayDto[], + query: CwTableQuery +): CwTableResult { + let rows = filterGatewayRows(buildGatewayTableRows(gateways), query.search ?? ''); + + if (query.sort) { + rows = sortGatewayRows(rows, query.sort.column, query.sort.direction); + } + + const total = rows.length; + const skip = (query.page - 1) * query.pageSize; + + return { + rows: rows.slice(skip, skip + query.pageSize), + total + }; +}