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
14 changes: 13 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
14 changes: 13 additions & 1 deletion messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "デバイス識別情報",
Expand Down Expand Up @@ -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": "トラック台数"
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions src/lib/components/dashboard/DashboardDeviceCards.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/lib/components/dashboard/DashboardDeviceTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -93,7 +94,7 @@
}

function isOffline(row: IDevice): boolean {
return isDashboardDeviceOffline(row);
return isDashboardDeviceOffline(row, reactiveNow.value);
}

async function loadData(query: CwTableQuery): Promise<CwTableResult<IDevice>> {
Expand Down
5 changes: 3 additions & 2 deletions src/lib/components/dashboard/dashboard-device-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ function getDeviceTimestampMs(device: Pick<IDevice, 'created_at'>): number {
}

export function isDashboardDeviceOffline(
device: Pick<IDevice, 'created_at' | 'has_primary_data'> & { raw_data?: Record<string, unknown> }
device: Pick<IDevice, 'created_at' | 'has_primary_data'> & { raw_data?: Record<string, unknown> },
nowMs = Date.now()
): boolean {
if (device.has_primary_data === false) {
return true;
Expand All @@ -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(
Expand Down
8 changes: 5 additions & 3 deletions src/lib/components/dashboard/device-cards.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
10 changes: 6 additions & 4 deletions src/lib/components/dashboard/device-cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ function getDeviceLabel(device: IDevice, duplicateCounts: Map<string, number>):
}


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 {
Expand Down Expand Up @@ -197,6 +197,7 @@ export function buildDeviceExpandedDetailRows(
function buildDashboardSensorCardEntry(
device: IDevice,
duplicateCounts: Map<string, number>,
nowMs: number,
deviceTypeLookup?: DeviceTypeLookup
): DashboardSensorCardEntry {
const label = getDeviceLabel(device, duplicateCounts);
Expand Down Expand Up @@ -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
};
Expand All @@ -245,6 +246,7 @@ function buildDashboardSensorCardEntry(
export function buildDashboardLocationSensorCards(
devices: IDevice[],
locations: LocationDto[],
nowMs: number,
deviceTypeLookup?: DeviceTypeLookup
): DashboardLocationSensorCard[] {
const locationsById = new Map(
Expand Down Expand Up @@ -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;
})
Expand Down
32 changes: 32 additions & 0 deletions src/lib/utils/reactive-now.svelte.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
16 changes: 9 additions & 7 deletions src/routes/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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') ?? '');
Expand Down Expand Up @@ -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) ──────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@
onAdd,
onRemove
}: Props = $props();

let open = $state(schedules.length > 0);
</script>

<CwExpandPanel title={m.reports_schedule_card_title()}>
<CwExpandPanel title={m.reports_schedule_card_title()} {open}>
<AppFormStack padded>
<div class="report-section-toolbar">
<p class="report-section-copy">{m.reports_schedule_card_copy()}</p>
Expand Down
Loading