diff --git a/.vscode/mcp.json b/.vscode/mcp.json index a728874b..1f291d5d 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -1,19 +1,27 @@ { - "inputs": [ - { - "type": "promptString", - "id": "supabase-access-token", - "description": "Supabase personal access token", - "password": true - } - ], - "servers": { - "supabase": { - "command": "npx", - "args": ["-y", "@supabase/mcp-server-supabase@latest"], - "env": { - "SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}" - } - } - } + "servers": { + "supabase": { + "command": "npx", + "args": [ + "-y", + "@supabase/mcp-server-supabase@latest" + ], + "env": { + "SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}" + }, + "type": "stdio" + }, + "SVELTEKIT-mcp-server-70cd8977": { + "url": "https://mcp.svelte.dev/mcp", + "type": "http" + } + }, + "inputs": [ + { + "type": "promptString", + "id": "supabase-access-token", + "description": "Supabase personal access token", + "password": true + } + ] } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..68dcb9e9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively: + +## Available MCP Tools: + +### 1. list-sections + +Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths. +When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections. + +### 2. get-documentation + +Retrieves full documentation content for specific sections. Accepts single or multiple sections. +After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task. + +### 3. svelte-autofixer + +Analyzes Svelte code and returns issues and suggestions. +You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned. + +### 4. playground-link + +Generates a Svelte Playground link with the provided code. +After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project. \ No newline at end of file diff --git a/DRAG_DROP_README.md b/DRAG_DROP_README.md deleted file mode 100644 index 03443142..00000000 --- a/DRAG_DROP_README.md +++ /dev/null @@ -1,203 +0,0 @@ -# DataRowItem Drag and Drop Implementation - -This implementation adds drag and drop functionality to reorder DataRowItem components using the colored status bar as a drag handle. - -## Features - -- **Drag Handle**: The colored status bar (red/green/blue div) on the left side serves as the drag handle -- **Visual Feedback**: Items show visual feedback during drag operations (opacity changes, drop target highlighting) -- **Smooth Animations**: Transitions and hover effects for better UX -- **Immediate Updates**: Order changes are applied immediately upon drop - -## Usage - -### 1. Basic Setup - -Import the required utilities and components: - -```typescript -import DataRowItem from '$lib/components/UI/dashboard/DataRowItem.svelte'; -import { createDragState, createDragHandlers, type DragState } from '$lib/utilities/dragAndDrop'; -``` - -### 2. Component State - -Set up the drag state and handlers: - -```typescript -// Your device data array -let devices = $state([/* your devices */]); - -// Drag state -let dragState: DragState = $state(createDragState()); - -function updateDragState(newState: Partial) { - dragState = { ...dragState, ...newState }; -} - -// Handle reordering -function handleDeviceReorder(newDevices: DeviceType[]) { - devices = newDevices; - // Optional: persist order to backend -} - -// Create drag handlers -let dragHandlers = $derived(createDragHandlers( - devices, - handleDeviceReorder, - dragState, - updateDragState -)); -``` - -### 3. Template Usage - -Use DataRowItem with drag props: - -```svelte -{#each devices as device, index (device.dev_eui)} - -{/each} -``` - -### 4. Container Components - -#### DeviceCards.svelte - -For list view with individual device cards: - -```svelte - { - // Handle reordering - devices = newDevices; - }} -/> -``` - -#### AllDevices.svelte - -For grouped devices by location: - -```svelte - { - // Handle reordering within location - const location = locations.find(l => l.location_id === locationId); - if (location) { - location.cw_devices = newDevices; - } - }} -/> -``` - -## Props Reference - -### DataRowItem Drag Props - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `dragEnabled` | `boolean` | `false` | Enable/disable drag functionality | -| `dragIndex` | `number` | `undefined` | Index of item in the array | -| `isDragging` | `boolean` | `false` | Whether this item is being dragged | -| `isDropTarget` | `boolean` | `false` | Whether this item is a drop target | -| `onDragStart` | `function` | `undefined` | Drag start handler | -| `onDragEnd` | `function` | `undefined` | Drag end handler | -| `onDragOver` | `function` | `undefined` | Drag over handler | -| `onDrop` | `function` | `undefined` | Drop handler | - -## Visual States - -### Drag Handle -- **Normal**: Colored status bar with normal opacity -- **Hover**: Increased opacity and slight scale on hover (when drag enabled) -- **Dragging**: Grabbing cursor, scale animation - -### Item States -- **Dragging**: 50% opacity -- **Drop Target**: Blue ring border and light blue background -- **Normal**: Default appearance - -## Demo - -See `src/lib/components/demo/DragDropDemo.svelte` for a working example. - -## Technical Details - -### Drag Data -- Uses `device.dev_eui` as the drag data identifier -- Effect allowed: `move` - -### Event Handling -- Prevents default browser drag behavior -- Manages drag state through centralized handlers -- Updates arrays using immutable patterns - -### Browser Compatibility -- Uses standard HTML5 Drag and Drop API -- Works in all modern browsers -- Fallback cursor states for better UX - -## Customization - -### Styling -The drag handle styling can be customized by modifying the classes in DataRowItem.svelte: - -```svelte -
-> -``` - -### Persistence -Implement order persistence by adding backend calls in your reorder handlers: - -```typescript -async function handleDeviceReorder(newDevices) { - devices = newDevices; - - // Save order to backend - await fetch('/api/devices/reorder', { - method: 'POST', - body: JSON.stringify({ - deviceOrder: newDevices.map(d => d.dev_eui) - }) - }); -} -``` - -## Troubleshooting - -### Common Issues - -1. **Drag not working**: Ensure `dragEnabled={true}` is set -2. **Visual feedback missing**: Check that drag state props are properly passed -3. **Order not updating**: Verify the reorder handler is updating the source array -4. **Performance issues**: Use proper key attributes in `{#each}` blocks - -### Debug Tips - -- Check browser console for drag event logs -- Verify drag state object updates -- Ensure unique keys for each item -- Test with browser dev tools drag simulation \ No newline at end of file diff --git a/package.json b/package.json index 8f2e59f9..df368738 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "devDependencies": { "@eslint/compat": "^1.4.0", "@eslint/js": "^9.37.0", - "@event-calendar/core": "^4.7.0", "@playwright/test": "^1.56.0", "@sveltejs/adapter-vercel": "^5.10.3", "@sveltejs/kit": "^2.46.5", @@ -39,7 +38,6 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.2.8", "@types/d3": "^7.4.3", - "@types/event-calendar__core": "^4.7.0", "@types/luxon": "^3.7.1", "@types/pdfkit": "^0.17.3", "@types/swagger-ui": "^5.21.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e1f7d78..e632af50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,9 +90,6 @@ importers: '@eslint/js': specifier: ^9.37.0 version: 9.37.0 - '@event-calendar/core': - specifier: ^4.7.0 - version: 4.7.0 '@playwright/test': specifier: ^1.56.0 version: 1.56.0 @@ -123,9 +120,6 @@ importers: '@types/d3': specifier: ^7.4.3 version: 7.4.3 - '@types/event-calendar__core': - specifier: ^4.7.0 - version: 4.7.0 '@types/luxon': specifier: ^3.7.1 version: 3.7.1 @@ -1078,9 +1072,6 @@ packages: resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@event-calendar/core@4.7.0': - resolution: {integrity: sha512-gAMihb0L55LQK+mWJoOAAAtEdPhcBcIzthfH3u6WOcqDeQb/Z/iQcnSDEmBprJ6G9Z6YRVeGPOweLW3+eNciwA==} - '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -1840,9 +1831,6 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/event-calendar__core@4.7.0': - resolution: {integrity: sha512-SPtSChJyamYDR92iMiARvS/3mK9tg6Imb9dO8sbTpqLzU7mRc6Sj8uSbnT0rzEz3NASjF3ChDelclJNplfXmTQ==} - '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} @@ -5929,10 +5917,6 @@ snapshots: '@eslint/core': 0.16.0 levn: 0.4.1 - '@event-calendar/core@4.7.0': - dependencies: - svelte: 5.39.11 - '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -6958,10 +6942,6 @@ snapshots: '@types/estree@1.0.8': {} - '@types/event-calendar__core@4.7.0': - dependencies: - svelte: 5.39.11 - '@types/geojson@7946.0.16': {} '@types/hast@2.3.10': diff --git a/src/lib/components/WeatherCalendar.svelte b/src/lib/components/WeatherCalendar.svelte index a7c487b7..daebab85 100644 --- a/src/lib/components/WeatherCalendar.svelte +++ b/src/lib/components/WeatherCalendar.svelte @@ -1,266 +1,278 @@ - -
- {#if loading} -
-
- Loading weather data... -
- {/if} -
- -
-

- Weather data by Open-Meteo -

-
+ } - +

+ Weather data by + + Open-Meteo + +

+ diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index d708ddbe..d9e23a22 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -298,6 +298,21 @@ export const strings = { permission_update_success: 'Permission updated successfully', permission_update_error: 'Failed to update permission', 'Location Settings': 'Location Settings', + 'Create Location': 'Create Location', + 'Add a new location to your account.': 'Add a new location to your account.', + 'Location Name': 'Location Name', + 'Location Description': 'Location Description', + 'Describe the location (optional)': 'Describe the location (optional)', + 'Pick a point on the map or adjust the values manually.': + 'Pick a point on the map or adjust the values manually.', + 'Use Current Location': 'Use Current Location', + 'Locating...': 'Locating...', + 'We use your browser location to prefill coordinates.': + 'We use your browser location to prefill coordinates.', + 'Browser location is unavailable; enter coordinates manually.': + 'Browser location is unavailable; enter coordinates manually.', + 'Location Preview': 'Location Preview', + 'Click the map to fine-tune the coordinates.': 'Click the map to fine-tune the coordinates.', 'Location Details': 'Location Details', 'Add Device': 'Add Device', 'Location ID': 'Location ID', diff --git a/src/lib/i18n/locales/ja.ts b/src/lib/i18n/locales/ja.ts index bb74c47a..f05629ec 100644 --- a/src/lib/i18n/locales/ja.ts +++ b/src/lib/i18n/locales/ja.ts @@ -339,9 +339,23 @@ export const strings = { // Location settings page 'Location Settings': 'ロケーション設定', + 'Create Location': 'ロケーションを作成', + 'Add a new location to your account.': 'アカウントに新しいロケーションを追加します。', + 'Location Name': 'ロケーション名', + 'Location Description': 'ロケーションの説明', + 'Describe the location (optional)': 'ロケーションの概要(任意)', + 'Pick a point on the map or adjust the values manually.': + '地図をクリックして地点を選ぶか、値を手動で調整してください。', + 'Use Current Location': '現在地を使用', + 'Locating...': '位置情報を取得中...', + 'We use your browser location to prefill coordinates.': + 'ブラウザの位置情報を利用して座標を自動入力します。', + 'Browser location is unavailable; enter coordinates manually.': + 'ブラウザの位置情報が利用できません。座標を手動で入力してください。', + 'Location Preview': 'ロケーションプレビュー', + 'Click the map to fine-tune the coordinates.': '地図をクリックして座標を微調整してください。', 'Location Details': 'ロケーション詳細', 'Edit Details': '詳細を編集', - 'Location Name': 'ロケーション名', Description: '説明', Coordinates: '座標', 'Not set': '未設定', diff --git a/src/lib/services/DeviceDataService.ts b/src/lib/services/DeviceDataService.ts index 71f509d5..4d2453bd 100644 --- a/src/lib/services/DeviceDataService.ts +++ b/src/lib/services/DeviceDataService.ts @@ -31,6 +31,10 @@ export class DeviceDataService implements IDeviceDataService { const tableName = cw_device.cw_device_type.data_table_v2; // Pull out the table name try { + if (tableName === 'cw_traffic2') { + return await this.getLatestTrafficAggregate(devEui); + } + const { data, error } = await this.supabase .from(tableName) .select() @@ -108,26 +112,27 @@ export class DeviceDataService implements IDeviceDataService { .from(tableName) .select('*') .eq('dev_eui', devEui) - .gte(tableName == 'cw_traffic2' ? 'traffic_hour' : 'created_at', utcStartDate.toISOString()) // SHIT FIX #1, traffic camera specific - .lte(tableName == 'cw_traffic2' ? 'traffic_hour' : 'created_at', utcEndDate.toISOString()) // SHIT FIX #2, traffic camera specific - .order(tableName == 'cw_traffic2' ? 'traffic_hour' : 'created_at', { ascending: false }); // SHIT FIX #3, traffic camera specific - // .limit(maxDataToReturn); - - // SHIT FIX #4, traffic camera specific - if (tableName == 'cw_traffic2') { - const trafficData = (data || []).map((record: any) => ({ - ...record, - created_at: record.traffic_hour, - dev_eui: record.dev_eui, - note: 'Traffic data formatted' - })) as DeviceDataRecord[]; - - // Convert timestamps back to user timezone - return this.convertRecordTimestampsToUserTimezone(trafficData, timezone, tableName); + .gte( + tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at', + utcStartDate.toISOString() + ) + .lte(tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at', utcEndDate.toISOString()) + .order(tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at', { + ascending: false + }); + + if (error) { + this.errorHandler.logError(error); + throw new Error(`Error fetching device data: ${error.message}`); + } + + let records: DeviceDataRecord[]; + if (tableName === 'cw_traffic2') { + records = this.aggregateTrafficCounts(data ?? []); + } else { + records = (data || []) as DeviceDataRecord[]; } - // END OF SHIT FIX - const records = (data || []) as DeviceDataRecord[]; // Convert timestamps back to user timezone return this.convertRecordTimestampsToUserTimezone(records, timezone, tableName); } catch (error) { @@ -199,16 +204,26 @@ export class DeviceDataService implements IDeviceDataService { .from(tableName) .select('*') .eq('dev_eui', devEui) - .gte(tableName == 'cw_traffic2' ? 'traffic_hour' : 'created_at', utcStartDate.toISOString()) // SHIT FIX #1, traffic camera specific - .lte(tableName == 'cw_traffic2' ? 'traffic_hour' : 'created_at', utcEndDate.toISOString()) // SHIT FIX #2, traffic camera specific - .order(tableName == 'cw_traffic2' ? 'traffic_hour' : 'created_at', { ascending: false }); // SHIT FIX #3, traffic camera specific + .gte( + tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at', + utcStartDate.toISOString() + ) + .lte(tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at', utcEndDate.toISOString()) + .order(tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at', { + ascending: false + }); if (error) { this.errorHandler.logError(error); throw new Error(`Error fetching CSV data: ${error.message}`); } - const rows = ((data || []) as Record[]).map((r) => ({ ...r })); + const rawRecords = + tableName === 'cw_traffic2' + ? this.aggregateTrafficCounts(data ?? []) + : ((data || []) as Record[]); + + const rows = rawRecords.map((r) => ({ ...r })); const timestampKey = tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at'; const normalizeToISO = (val: string) => { @@ -484,6 +499,127 @@ export class DeviceDataService implements IDeviceDataService { return true; // Default to true for now } + private aggregateTrafficCounts(records: Record[]): DeviceDataRecord[] { + if (!records || records.length === 0) { + return []; + } + + const grouped = new Map(); + + const toNumber = (value: unknown): number => { + if (typeof value === 'number') return value; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.length === 0) return 0; + const parsed = Number(trimmed); + return Number.isNaN(parsed) ? 0 : parsed; + } + return 0; + }; + + for (const record of records) { + if (!record) continue; + + const devEui = record.dev_eui; + const trafficHour = record.traffic_hour; + if (!devEui || !trafficHour) continue; + + const key = `${devEui}__${trafficHour}`; + let aggregated = grouped.get(key); + + if (!aggregated) { + aggregated = { + dev_eui: devEui, + traffic_hour: trafficHour, + created_at: trafficHour + } as DeviceDataRecord; + grouped.set(key, aggregated); + } + + for (const [field, value] of Object.entries(record)) { + if (!field.endsWith('_count')) continue; + if (field === 'line_number') continue; + + const existing = (aggregated as Record)[field] ?? 0; + (aggregated as Record)[field] = existing + toNumber(value); + } + } + + const results = Array.from(grouped.values()); + results.sort((a, b) => { + const aDate = new Date((a.traffic_hour as string) ?? (a.created_at as string) ?? 0); + const bDate = new Date((b.traffic_hour as string) ?? (b.created_at as string) ?? 0); + return bDate.getTime() - aDate.getTime(); + }); + + const ensureFields = [ + 'people_count', + 'bicycle_count', + 'motorcycle_count', + 'car_count', + 'truck_count', + 'bus_count' + ]; + + return results.map((record) => { + const copy = { ...record } as Record; + for (const field of ensureFields) { + if (copy[field] === undefined || copy[field] === null) { + copy[field] = 0; + } + } + delete copy.line_number; + delete copy.id; + return copy as DeviceDataRecord; + }); + } + + private async getLatestTrafficAggregate(devEui: string): Promise { + const { data: latestRows, error } = await this.supabase + .from('cw_traffic2') + .select('*') + .eq('dev_eui', devEui) + .order('traffic_hour', { ascending: false }) + .limit(10); + + if (error) { + this.errorHandler.logError(error); + throw new Error(`Error fetching latest traffic data: ${error.message}`); + } + + if (!latestRows || latestRows.length === 0) { + return null; + } + + const latestHour = latestRows[0]?.traffic_hour; + if (!latestHour) { + return null; + } + + const rowsForHour = latestRows.filter((row) => row?.traffic_hour === latestHour); + + // If the limited query includes all lines for the hour, aggregate directly. + if (rowsForHour.length === latestRows.length) { + const [aggregated] = this.aggregateTrafficCounts(rowsForHour); + return aggregated ?? null; + } + + // Otherwise fetch all rows for the hour to guarantee completeness. + const { data: completeHourRows, error: hourError } = await this.supabase + .from('cw_traffic2') + .select('*') + .eq('dev_eui', devEui) + .eq('traffic_hour', latestHour); + + if (hourError) { + this.errorHandler.logError(hourError); + throw new Error(`Error fetching traffic data for hour ${latestHour}: ${hourError.message}`); + } + + const [aggregated] = this.aggregateTrafficCounts(completeHourRows ?? []); + return aggregated ?? null; + } + /** * Convert user timezone dates to UTC for database queries * @param date Date in user's timezone diff --git a/src/lib/stores/LocationsStore.svelte.ts b/src/lib/stores/LocationsStore.svelte.ts index b24179a2..f880dbc4 100644 --- a/src/lib/stores/LocationsStore.svelte.ts +++ b/src/lib/stores/LocationsStore.svelte.ts @@ -175,7 +175,7 @@ function updateSingleDevice(devEui: string, updatedData: AirData | SoilData) { // Filter out null values and unwanted properties const newData = Object.fromEntries( Object.entries(updatedData).filter( - ([k, v]) => v != null && k !== 'is_simulated' && k !== 'dev_eui' + ([k, v]) => v != null && k !== 'is_simulated' && k !== 'dev_eui' && k !== 'line_number' ) ) as AirData | SoilData; diff --git a/src/lib/utilities/NameToEmoji.ts b/src/lib/utilities/NameToEmoji.ts index 6b7baecc..66432e20 100644 --- a/src/lib/utilities/NameToEmoji.ts +++ b/src/lib/utilities/NameToEmoji.ts @@ -68,6 +68,12 @@ export const nameToEmoji = (name: string) => { return '🚗'; case 'bicycle_count': return '🚲'; + case 'motorcycle_count': + return '🏍️'; + case 'truck_count': + return '🚚'; + case 'bus_count': + return '🚌'; case 'relay_1': case 'relay_2': return '🔌'; diff --git a/src/routes/app/dashboard/+page.svelte b/src/routes/app/dashboard/+page.svelte index 4061370b..f4d0a68a 100644 --- a/src/routes/app/dashboard/+page.svelte +++ b/src/routes/app/dashboard/+page.svelte @@ -265,15 +265,36 @@ } // Handle real-time update - function handleRealtimeUpdate(payload: any) { + async function handleRealtimeUpdate(payload: any) { // Only process if we have valid data if (payload.new && payload.new.dev_eui) { + const tableName = payload.table; console.debug('[Dashboard] Postgres change received', { eventType: payload.eventType, - table: payload.table, + table: tableName, dev_eui: payload.new.dev_eui, created_at: payload.new.created_at }); + if (tableName === 'cw_traffic2') { + try { + const response = await fetch(`/api/devices/${payload.new.dev_eui}/status`); + if (!response.ok) { + console.warn( + `[Dashboard] Failed to refresh aggregated traffic data for ${payload.new.dev_eui}`, + response.status + ); + return; + } + const aggregated = await response.json(); + applyDeviceDataUpdate(aggregated as AirData | SoilData); + } catch (err) { + console.error( + `[Dashboard] Error refreshing aggregated traffic data for ${payload.new.dev_eui}`, + err + ); + } + return; + } applyDeviceDataUpdate(payload.new as AirData | SoilData); } else { console.debug('[Dashboard] Postgres change ignored', payload); diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.server.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.server.ts index 26391b3c..4506880b 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.server.ts +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.server.ts @@ -7,22 +7,39 @@ import { DateTime } from 'luxon'; * Load the latest and historical device data for a specific device EUI. * User session, `devEui` and `locationId` are already validated in the layout server load. */ -export const load: PageServerLoad = async ({ url, params, locals: { supabase } }) => { +export const load: PageServerLoad = async ({ url, params, parent, locals: { supabase } }) => { const { devEui } = params; try { + const parentData = await parent(); + const dataTable = parentData?.dataType; const deviceDataService = new DeviceDataService(supabase); const startParam = url.searchParams.get('start'); const endParam = url.searchParams.get('end'); const timezoneParam = url.searchParams.get('timezone') || 'UTC'; - let startDate: Date, endDate: Date; + let startDate: Date; + let endDate: Date; + const nowInZone = DateTime.now().setZone(timezoneParam); + const now = nowInZone.isValid ? nowInZone : DateTime.now(); + if (!startParam || !endParam) { - startDate = DateTime.now().minus({ days: 1 }).startOf('day').toJSDate(); - endDate = DateTime.now().endOf('day').toJSDate(); + if (dataTable === 'cw_traffic2') { + startDate = now.startOf('month').startOf('day').toJSDate(); + endDate = now.endOf('day').toJSDate(); + } else { + startDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + endDate = now.endOf('day').toJSDate(); + } } else { - startDate = DateTime.fromISO(startParam).startOf('day').toJSDate(); - endDate = DateTime.fromISO(endParam).endOf('day').toJSDate(); + const startInZone = DateTime.fromISO(startParam, { zone: timezoneParam }); + const endInZone = DateTime.fromISO(endParam, { zone: timezoneParam }); + startDate = (startInZone.isValid ? startInZone : DateTime.fromISO(startParam)) + .startOf('day') + .toJSDate(); + endDate = (endInZone.isValid ? endInZone : DateTime.fromISO(endParam)) + .endOf('day') + .toJSDate(); } // Don’t `await` the promise, stream the data instead diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte index f91ccb8b..8347db1b 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte @@ -26,6 +26,7 @@ import RelayControl from '$lib/components/RelayControl.svelte'; import { browser } from '$app/environment'; import { createActiveTimer } from '$lib/utilities/ActiveTimer'; + import { DateTime } from 'luxon'; // Get device data from server load function let { data }: PageProps = $props(); @@ -319,6 +320,81 @@ }); const updateEvents = (data: any[] = historicalData): CalendarEvent[] => { + if (!data || data.length === 0) { + return []; + } + + const tableName = device.cw_device_type?.data_table_v2; + + if (['cw_traffic2', 'traffic_v2'].includes(tableName ?? '')) { + const monthStart = DateTime.now().startOf('month'); + const todayEnd = DateTime.now().endOf('day'); + const grouped = new Map< + string, + { date: Date; counts: Record; total: number } + >(); + + const toNumber = (value: unknown): number => { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : 0; + } + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; + }; + + for (const event of data) { + const timestamp = (event?.traffic_hour as string | undefined) ?? event?.created_at; + if (!timestamp) continue; + + const eventDate = DateTime.fromISO(timestamp); + if (!eventDate.isValid) continue; + if (eventDate < monthStart || eventDate > todayEnd) continue; + + const key = eventDate.toISODate(); + let entry = grouped.get(key); + if (!entry) { + entry = { + date: eventDate.startOf('day').toJSDate(), + counts: {}, + total: 0 + }; + grouped.set(key, entry); + } + + for (const [field, value] of Object.entries(event)) { + if (!field.endsWith('_count')) continue; + const numeric = toNumber(value); + entry.counts[field] = (entry.counts[field] ?? 0) + numeric; + entry.total += numeric; + } + } + + const formatTrafficTitle = (counts: Record, total: number) => { + const lines: string[] = [`Total: ${total.toLocaleString()}`]; + const orderedKeys = Object.keys(counts).sort(); + for (const key of orderedKeys) { + const value = counts[key] ?? 0; + if (!value) continue; + lines.push(`${$_(key)}: ${formatNumber({ key, value })}`); + } + return lines.join('
'); + }; + + return Array.from(grouped.values()) + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .map(({ date, counts, total }) => ({ + id: date, + start: date, + end: date, + allDay: true, + title: { html: formatTrafficTitle(counts, total) }, + display: 'auto' + })); + } + // Group data by date const dailyStats: { [date: string]: Record } = {}; @@ -466,7 +542,7 @@ {#if latestData}
- {#each Object.keys(latestData) as key} + {#each Object.keys(latestData) as key (key)} {#if !['id', 'dev_eui', 'created_at', 'is_simulated', 'battery_level', 'vape_detected', 'smoke_detected', 'traffic_hour'].includes(key) && latestData[key] !== null} {#if device.cw_device_type?.data_table_v2 === 'cw_air_data'} - {:else if device.cw_device_type?.data_table_v2 === 'traffic_v2'} + {:else if ['traffic_v2', 'cw_traffic2'].includes(device.cw_device_type?.data_table_v2 ?? '')}

{$_('Weather & Data')}

- + {:else} {/if} diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/realtime.svelte.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/realtime.svelte.ts index ec1469d0..e2fb1d74 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/realtime.svelte.ts +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/realtime.svelte.ts @@ -16,6 +16,23 @@ export function setupRealtimeSubscription( ) { if (!browser) return; + const refreshAggregatedTraffic = async () => { + try { + const response = await fetch(`/api/devices/${devEui}/status`); + if (!response.ok) { + console.warn( + `[DeviceRealtime] Failed to refresh aggregated traffic data for ${devEui}`, + response.status + ); + return; + } + const aggregated = await response.json(); + onDataUpdate(aggregated); + } catch (error) { + console.error('[DeviceRealtime] Error refreshing aggregated traffic data', error); + } + }; + console.log('🔄 Setting up real-time subscription...'); channel = supabase .channel(`${devEui}-changes`) @@ -31,7 +48,11 @@ export function setupRealtimeSubscription( // Handle real-time updates for users if (payload.eventType === 'UPDATE' || payload.eventType === 'INSERT') { console.log('📡 Real-time data received:', payload.new); - onDataUpdate(payload.new); + if (deviceDataTable === 'cw_traffic2') { + void refreshAggregatedTraffic(); + } else { + onDataUpdate(payload.new); + } } } ) diff --git a/src/routes/app/dashboard/location/create/+page.svelte b/src/routes/app/dashboard/location/create/+page.svelte index a963bc17..28677a13 100644 --- a/src/routes/app/dashboard/location/create/+page.svelte +++ b/src/routes/app/dashboard/location/create/+page.svelte @@ -1,64 +1,198 @@ -
-

Create Location

+
+
+

+ {$_('Create Location')} +

+

+ {$_('Add a new location to your account.')} +

+
-
- - + +
+
+
+ + +
- - +
+ + +

+ {$_('Pick a point on the map or adjust the values manually.')} +

+
- - - - +
+
+
+ + +
+
+ + +
+
-
- {#if browser && latitude !== null && longitude !== null} - { - latitude = lat; - longitude = lon; - }} - showClickMarker={true} - /> - {/if} + {#if geolocationAvailable} +
+ +

+ {$_('We use your browser location to prefill coordinates.')} +

+
+ {:else} +

+ {$_('Browser location is unavailable; enter coordinates manually.')} +

+ {/if} +
+
+ +
+
+

+ {$_('Location Preview')} +

+
+

+ {$_('Click the map to fine-tune the coordinates.')} +

+
+ {#if browser} + + {/if} +
+
- - -
+
+ +
+ +
diff --git a/src/routes/dragtest/+page.svelte b/src/routes/dragtest/+page.svelte deleted file mode 100644 index 05561339..00000000 --- a/src/routes/dragtest/+page.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - - - Drag and Drop Test - - -
- -