diff --git a/messages/en.json b/messages/en.json index e0bee1ae..06ca015c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -671,6 +671,11 @@ "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", @@ -885,5 +890,12 @@ "stat_range": "Range", "stat_aboveAvg": "Above Avg", "stat_belowAvg": "Below Avg", - "stat_atAvg": "At Avg" + "stat_atAvg": "At Avg", + "traffic_metric_bicycle_count": "Bicycle Count", + "traffic_metric_bus_count": "Bus Count", + "traffic_metric_car_count": "Car Count", + "traffic_metric_motorcycle_count": "Motorcycle Count", + "traffic_metric_people_count": "People Count", + "traffic_metric_train_count": "Train Count", + "traffic_metric_truck_count": "Truck Count" } diff --git a/messages/ja.json b/messages/ja.json index 06cf796a..dee67e11 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -671,6 +671,11 @@ "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": "デバイス識別情報", @@ -887,5 +892,12 @@ "stat_belowAvg": "平均以下", "stat_atAvg": "平均", "stat_expand": "展開", - "stat_collapse": "折りたたむ" + "stat_collapse": "折りたたむ", + "traffic_metric_bicycle_count": "自転車台数", + "traffic_metric_bus_count": "バス台数", + "traffic_metric_car_count": "車台数", + "traffic_metric_motorcycle_count": "バイク台数", + "traffic_metric_people_count": "人数", + "traffic_metric_train_count": "電車本数", + "traffic_metric_truck_count": "トラック台数" } diff --git a/package.json b/package.json index 5194d121..058d811f 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ }, "packageManager": "pnpm@10.18.2+sha512.9fb969fa749b3ade6035e0f109f0b8a60b5d08a1a87fdf72e337da90dcc93336e2280ca4e44f2358a649b83c17959e9993e777c2080879f3801e6f0d999ad3dd", "dependencies": { - "@cropwatchdevelopment/cwui": "0.1.81", + "@cropwatchdevelopment/cwui": "0.1.82", "@supabase/supabase-js": "^2.98.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bfc4078..c232fb13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@cropwatchdevelopment/cwui': - specifier: 0.1.81 - version: 0.1.81(svelte@5.53.0) + specifier: 0.1.82 + version: 0.1.82(svelte@5.53.0) '@supabase/supabase-js': specifier: ^2.98.0 version: 2.98.0 @@ -117,8 +117,8 @@ importers: packages: - '@cropwatchdevelopment/cwui@0.1.81': - resolution: {integrity: sha512-ty48ciFa18hthih+sw3LboQx0NvB+iMx91SICyzG0e1HvLoeufFsnOi6YatNiFbSpKE2Yj93KukX2H3fHAWQ+w==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.81/4f46216a4dfd83b279b5f5eb8510b73da94e5f66} + '@cropwatchdevelopment/cwui@0.1.82': + resolution: {integrity: sha512-oIk6QKCeAwCabO40NABwPIwe2hlfP+/sFy+/riRG3qzJbrgAJhgJFC+X9cgW7zUi/qb5X4wUQWCmHDDxh/SOJQ==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.82/9189c6d2fd170c2100f95b49f8bc26183b8a5be1} peerDependencies: svelte: ^5.0.0 @@ -2147,7 +2147,7 @@ packages: snapshots: - '@cropwatchdevelopment/cwui@0.1.81(svelte@5.53.0)': + '@cropwatchdevelopment/cwui@0.1.82(svelte@5.53.0)': dependencies: svelte: 5.53.0 diff --git a/src/lib/components/dashboard/DashboardDeviceCards.svelte b/src/lib/components/dashboard/DashboardDeviceCards.svelte index b5dc2a7f..70abf778 100644 --- a/src/lib/components/dashboard/DashboardDeviceCards.svelte +++ b/src/lib/components/dashboard/DashboardDeviceCards.svelte @@ -28,6 +28,7 @@ } from './dashboard-device-refresh'; import { resolveDeviceTypeConfig } from './dashboard-device-data'; import { ApiService } from '$lib/api/api.service'; + import { reactiveNow } from '$lib/utils/reactive-now.svelte'; import { listDashboardDevices, type DashboardDeviceFilters } from './device-table'; export type CardLayout = 'grid' | 'masonry'; @@ -66,13 +67,18 @@ listDashboardDevices(app.devices ?? [], app.locations ?? [], filters, search) ); let locationCards = $derived( - buildDashboardLocationSensorCards(filteredDevices, app.locations ?? [], app.deviceTypeLookup) + buildDashboardLocationSensorCards( + filteredDevices, + app.locations ?? [], + reactiveNow.value, + app.deviceTypeLookup + ) ); let deviceRefreshPlans = $derived( filteredDevices.map((device) => ({ device, alarmId: getCardRefreshAlarmId(device.dev_eui), - delayMs: getDashboardDeviceNextRefreshDelayMs(device) + delayMs: getDashboardDeviceNextRefreshDelayMs(device, reactiveNow.value) })) ); let enableInfiniteScroll = $derived(viewportWidth > 0); diff --git a/src/lib/components/dashboard/DashboardDeviceTable.svelte b/src/lib/components/dashboard/DashboardDeviceTable.svelte index d237c175..02c904ea 100644 --- a/src/lib/components/dashboard/DashboardDeviceTable.svelte +++ b/src/lib/components/dashboard/DashboardDeviceTable.svelte @@ -31,6 +31,7 @@ DASHBOARD_DEVICE_PAGE_SIZE_OPTIONS, DASHBOARD_DEVICE_ROW_HEIGHT } from './device-table'; + import { reactiveNow } from '$lib/utils/reactive-now.svelte'; import EYE_ICON from '$lib/images/icons/eye.svg'; interface Props { @@ -93,7 +94,7 @@ } function isOffline(row: IDevice): boolean { - return isDashboardDeviceOffline(row); + return isDashboardDeviceOffline(row, reactiveNow.value); } async function loadData(query: CwTableQuery): Promise> { diff --git a/src/lib/components/dashboard/dashboard-device-refresh.ts b/src/lib/components/dashboard/dashboard-device-refresh.ts index df2bbc48..8ec8c8c2 100644 --- a/src/lib/components/dashboard/dashboard-device-refresh.ts +++ b/src/lib/components/dashboard/dashboard-device-refresh.ts @@ -35,7 +35,8 @@ function getDeviceTimestampMs(device: Pick): number { } export function isDashboardDeviceOffline( - device: Pick & { raw_data?: Record } + device: Pick & { raw_data?: Record }, + nowMs = Date.now() ): boolean { if (device.has_primary_data === false) { return true; @@ -53,7 +54,7 @@ export function isDashboardDeviceOffline( ? intervalMinutes * 60_000 : DASHBOARD_DEVICE_OFFLINE_THRESHOLD_MS; - return lastSeenMs < Date.now() - thresholdMs; + return lastSeenMs < nowMs - thresholdMs; } export function getDashboardDeviceNextRefreshDelayMs( diff --git a/src/lib/components/dashboard/device-cards.spec.ts b/src/lib/components/dashboard/device-cards.spec.ts index dac645cf..dd275465 100644 --- a/src/lib/components/dashboard/device-cards.spec.ts +++ b/src/lib/components/dashboard/device-cards.spec.ts @@ -57,7 +57,7 @@ describe('device-cards helpers', () => { } as LocationDto ]; - const cards = buildDashboardLocationSensorCards(devices, locations); + const cards = buildDashboardLocationSensorCards(devices, locations, Date.now()); expect(cards.map((card) => card.title)).toEqual(['Atrium', 'Zone B']); expect(cards[1]?.sensors.map(({ sensor }) => sensor.label)).toEqual([ @@ -108,7 +108,8 @@ describe('device-cards helpers', () => { name: 'Zone A', created_at: '2026-03-01T00:00:00.000Z' } as LocationDto - ] + ], + Date.now() ); expect(cards[0]?.sensors[0]?.sensor).toMatchObject({ @@ -150,7 +151,8 @@ describe('device-cards helpers', () => { name: 'Zone X', created_at: '2026-03-01T00:00:00.000Z' } as LocationDto - ] + ], + Date.now() ); expect(cards[0]?.sensors[0]?.sensor).toMatchObject({ diff --git a/src/lib/components/dashboard/device-cards.ts b/src/lib/components/dashboard/device-cards.ts index 27f591dc..3cfb9054 100644 --- a/src/lib/components/dashboard/device-cards.ts +++ b/src/lib/components/dashboard/device-cards.ts @@ -40,8 +40,8 @@ function getDeviceLabel(device: IDevice, duplicateCounts: Map): } -function getDeviceStatus(device: IDevice): 'online' | 'offline' { - return isDashboardDeviceOffline(device) ? 'offline' : 'online'; +function getDeviceStatus(device: IDevice, nowMs: number): 'online' | 'offline' { + return isDashboardDeviceOffline(device, nowMs) ? 'offline' : 'online'; } function getSensorStorageKey(device: IDevice): string { @@ -197,6 +197,7 @@ export function buildDeviceExpandedDetailRows( function buildDashboardSensorCardEntry( device: IDevice, duplicateCounts: Map, + nowMs: number, deviceTypeLookup?: DeviceTypeLookup ): DashboardSensorCardEntry { const label = getDeviceLabel(device, duplicateCounts); @@ -236,7 +237,7 @@ function buildDashboardSensorCardEntry( secondaryValue: typeof rawSecondary === 'number' ? rawSecondary : Number(rawSecondary) || 0, secondaryUnit: typeConfig?.secondary_data_notation ?? '%', }), - status: getDeviceStatus(device), + status: getDeviceStatus(device, nowMs), lastUpdated: device.created_at } satisfies CwSensorCardDevice }; @@ -245,6 +246,7 @@ function buildDashboardSensorCardEntry( export function buildDashboardLocationSensorCards( devices: IDevice[], locations: LocationDto[], + nowMs: number, deviceTypeLookup?: DeviceTypeLookup ): DashboardLocationSensorCard[] { const locationsById = new Map( @@ -289,7 +291,7 @@ export function buildDashboardLocationSensorCards( locationId, title: getLocationTitle(locationId, locationsById, sortedLocationDevices), sensors: sortedLocationDevices.map((device) => - buildDashboardSensorCardEntry(device, duplicateCounts, deviceTypeLookup) + buildDashboardSensorCardEntry(device, duplicateCounts, nowMs, deviceTypeLookup) ) } satisfies DashboardLocationSensorCard; }) diff --git a/src/lib/utils/reactive-now.svelte.ts b/src/lib/utils/reactive-now.svelte.ts new file mode 100644 index 00000000..c841e4eb --- /dev/null +++ b/src/lib/utils/reactive-now.svelte.ts @@ -0,0 +1,32 @@ +/** + * Reactive wall-clock tick. + * + * Svelte 5 derivations only re-run when their tracked reactive reads change. + * `Date.now()` is not reactive on its own, so derivations that compare a + * timestamp against "now" (e.g. "is this device offline?") become stale when + * wall-clock time crosses a threshold without any other state change. + * + * `reactiveNow.value` is a `$state`-backed millisecond timestamp that advances + * on a shared interval. Reading it inside a `$derived` or `$effect` registers + * it as a reactive dependency, so the derivation invalidates each tick. + */ + +const TICK_MS = 30_000; + +let nowMs = $state(Date.now()); + +if (typeof window !== 'undefined') { + setInterval(() => { + nowMs = Date.now(); + }, TICK_MS); +} + +/** + * Reactive "now" in milliseconds. Read `reactiveNow.value` inside a `$derived` + * or `$effect` to make the derivation re-run on each tick. + */ +export const reactiveNow = { + get value(): number { + return nowMs; + } +}; diff --git a/src/routes/Sidebar.svelte b/src/routes/Sidebar.svelte index 3847d3d5..98c1ff79 100644 --- a/src/routes/Sidebar.svelte +++ b/src/routes/Sidebar.svelte @@ -28,6 +28,8 @@ 'M3 4.5h6M3 8h6M3 11.5h6M10.5 4.5l1 1 2-2M10.5 8l1 1 2-2M10.5 11.5l1 1 2-2'; 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'; // ── Read active filters from URL search params ────────────── let selectedGroup = $derived(page.url.searchParams.get('group') ?? ''); let selectedLocation = $derived(page.url.searchParams.get('location') ?? ''); @@ -60,14 +62,14 @@ href: '/reports', icon: { path: REPORTS_ICON_PATH }, group: 'Info and Management' + }, + { + id: 'gateways', + label: 'Gateways', + href: '/gateways', + icon: { path: GATEWAYS_ICON_PATH }, + group: 'Connectivity & Hardware' } - // { - // id: 'gateways', - // label: 'Gateways', - // href: '/gateways', - // icon: { path: GATEWAYS_ICON_PATH }, - // group: 'Connectivity & Hardware' - // } ]); // ── Groups list (dynamic from API) ────────────────────────── diff --git a/src/routes/reports/[report_id]/edit/ReportProcessingSchedulesSection.svelte b/src/routes/reports/[report_id]/edit/ReportProcessingSchedulesSection.svelte index 522da143..ac7b5b45 100644 --- a/src/routes/reports/[report_id]/edit/ReportProcessingSchedulesSection.svelte +++ b/src/routes/reports/[report_id]/edit/ReportProcessingSchedulesSection.svelte @@ -27,9 +27,11 @@ onAdd, onRemove }: Props = $props(); + + let open = $state(schedules.length > 0); - +

{m.reports_schedule_card_copy()}