Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
20 changes: 10 additions & 10 deletions messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": "オンライン",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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": "デバイス識別情報",
Expand Down Expand Up @@ -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": "車台数",
Expand Down
2 changes: 1 addition & 1 deletion src/routes/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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') ?? '');
Expand Down
53 changes: 10 additions & 43 deletions src/routes/gateways/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<GatewayDto>[] = [
const columns: CwColumnDef<GatewayTableRow>[] = [
{ 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<string, unknown>)[column];
const bVal = (b as unknown as Record<string, unknown>)[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<CwTableResult<GatewayDto>> {
const search = query.search?.trim().toLowerCase() || '';
async function loadData(query: CwTableQuery): Promise<CwTableResult<GatewayTableRow>> {
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);
}
</script>

Expand All @@ -81,8 +48,8 @@
</CwButton>

<CwCard title={m.gateways_your_gateways()}>
<CwDataTable {columns} {loadData} {loading} rowKey="id" class="w-full">
{#snippet cell(row: GatewayDto, col: CwColumnDef<GatewayDto>, defaultValue: string)}
<CwDataTable {columns} {loadData} {loading} rowKey="tableRowKey" class="w-full">
{#snippet cell(row: GatewayTableRow, col: CwColumnDef<GatewayTableRow>, defaultValue: string)}
{#if col.key === 'is_online'}
{#if row.is_online}
<Icon src={CHECK_ICON} alt={m.gateways_online()} preserveColor />
Expand All @@ -99,8 +66,8 @@
<Icon src={NO_ICON} alt={m.gateways_private()} />
</span>
{/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}
Expand Down
68 changes: 68 additions & 0 deletions src/routes/gateways/gateway-table.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): CwTableQuery {
return {
page: 1,
pageSize: 25,
search: '',
sort: null,
filters: {},
signal: new AbortController().signal,
...overrides
};
}

function gateway(overrides: Partial<GatewayDto>): 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);
});
});
109 changes: 109 additions & 0 deletions src/routes/gateways/gateway-table.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();

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<string, unknown>)[column];
const rightValue = (right as unknown as Record<string, unknown>)[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<GatewayTableRow> {
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
};
}
Loading