From abf51ee5da007947d9d44cdac5c8ebaf41fe1aff Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Sun, 14 Sep 2025 01:15:18 +0900 Subject: [PATCH 01/18] styles looking better in all modes --- THEME.md | 60 +++++++ src/app.css | 155 +++++++++++++++++- src/lib/components/DataCard/DataCard.svelte | 2 +- src/lib/components/GlobalSidebar.svelte | 35 ++-- src/lib/components/Header.svelte | 82 +++++---- src/lib/components/StatsCard/StatsCard.svelte | 2 +- src/lib/components/UI/buttons/Button.svelte | 111 ++++--------- .../UI/dashboard/DashboardFilterBits.svelte | 13 +- .../UI/dashboard/components/Button.svelte | 44 +++-- .../dashboard/DateRangeSelector.svelte | 2 +- .../components/theme/ThemeModeSelector.svelte | 55 +++++++ src/lib/components/theme/theme.svelte.ts | 66 +++----- src/lib/components/ui/base/Button.svelte | 50 +++--- src/lib/stores/theme.ts | 124 ++++++++++++++ src/routes/+layout.svelte | 12 +- .../api/devices/[devEui]/pdf/+server.ts | 117 +++++++++++-- .../location/[location_id]/+page.svelte | 2 +- .../devices/[devEui]/+page.svelte | 26 ++- src/routes/auth/login/+page.svelte | 3 +- 19 files changed, 686 insertions(+), 275 deletions(-) create mode 100644 THEME.md create mode 100644 src/lib/components/theme/ThemeModeSelector.svelte create mode 100644 src/lib/stores/theme.ts diff --git a/THEME.md b/THEME.md new file mode 100644 index 00000000..b8c11205 --- /dev/null +++ b/THEME.md @@ -0,0 +1,60 @@ +# Theme System + +Centralized theme management supports three modes: `light`, `dark`, and `system`. + +## How It Works +- User preference stored in `localStorage` under `theme.mode`. +- `themeStore` (in `src/lib/stores/theme.ts`) exposes `{ mode, effective, system }`. +- `effective` is the applied theme (`light` or `dark`). If `mode === 'system'`, it mirrors OS preference; otherwise user selection overrides OS. +- The `` element gets the `dark` class when effective theme is dark, plus a `data-theme="light|dark"` attribute for additional hooks. + +## API +```ts +import { themeStore, setThemeMode, toggleExplicitLightDark } from '$lib/stores/theme'; +setThemeMode('dark'); // force dark +setThemeMode('light'); // force light +setThemeMode('system'); // follow OS +``` + +## UI Component: ThemeModeSelector +Use the built-in selector component for a user-facing control: +```svelte + + + +``` +Desktop renders a segmented control; mobile falls back to a ` apply((e.target as HTMLSelectElement).value as ThemeMode)} + aria-label="Theme mode" + > + {#each options as opt} + + {/each} + + + + diff --git a/src/lib/components/theme/theme.svelte.ts b/src/lib/components/theme/theme.svelte.ts index 2301b6c1..f5ef97b7 100644 --- a/src/lib/components/theme/theme.svelte.ts +++ b/src/lib/components/theme/theme.svelte.ts @@ -5,18 +5,14 @@ * Initialize theme based on localStorage or system preference * @returns {boolean} True if dark mode should be active */ -function initializeTheme(): boolean { - // Handle SSR case - if (typeof window === 'undefined') return false; - - // Check localStorage first for user preference - const savedTheme = localStorage.getItem('theme'); - - if (savedTheme === 'dark') return true; - if (savedTheme === 'light') return false; +// NOTE: This legacy module is now a thin wrapper over the central themeStore. +// It is kept to avoid breaking existing imports (ThemeToggle etc.). +import { themeStore, setThemeMode, toggleExplicitLightDark } from '$lib/stores/theme'; +import { get } from 'svelte/store'; - // Fall back to system preference if no saved preference - return window.matchMedia('(prefers-color-scheme: dark)').matches; +function initializeTheme(): boolean { + // Derive initial from store (store already handled localStorage + system) + return get(themeStore).effective === 'dark'; } // Create private module-level state variables with default values for SSR @@ -57,6 +53,11 @@ if (typeof window !== 'undefined') { // Start observing the document with the configured parameters observer.observe(document.documentElement, { attributes: true }); + + // Keep legacy _theme state in sync with central store so getDarkMode() reflects selector changes + themeStore.subscribe((v) => { + _theme.darkMode = v.effective === 'dark'; + }); } // Export getter functions instead of the state directly @@ -71,23 +72,16 @@ export function getSystemPrefersDark(): boolean { // Function to update the DOM and localStorage when theme changes // This is called by components, not at the module level export function applyTheme(isDark: boolean): void { - if (typeof window === 'undefined') return; - - if (isDark) { - document.documentElement.classList.add('dark'); - localStorage.setItem('theme', 'dark'); - } else { - document.documentElement.classList.remove('dark'); - localStorage.setItem('theme', 'light'); - } + // Delegate to theme store explicit mode set + setThemeMode(isDark ? 'dark' : 'light'); } /** * Toggle between light and dark mode */ export function toggleTheme(): void { - _theme.darkMode = !_theme.darkMode; - applyTheme(_theme.darkMode); + toggleExplicitLightDark(); + _theme.darkMode = get(themeStore).effective === 'dark'; } /** @@ -95,31 +89,9 @@ export function toggleTheme(): void { * @param {boolean} dark - True to set dark mode, false for light mode */ export function setTheme(dark: boolean): void { - _theme.darkMode = dark; - applyTheme(_theme.darkMode); + setThemeMode(dark ? 'dark' : 'light'); + _theme.darkMode = get(themeStore).effective === 'dark'; } // Set up listener for system preference changes -if (typeof window !== 'undefined') { - const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - - // Update system preference state and apply theme if no localStorage preference exists - darkModeMediaQuery.addEventListener('change', (e) => { - _theme.systemPrefersDark = e.matches; - - // Check if user has a saved preference - const savedTheme = localStorage.getItem('theme'); - - // Only follow system preference if no explicit user preference exists - if (!savedTheme) { - _theme.darkMode = e.matches; - applyTheme(e.matches); - //console.log('System preference changed, applying:', e.matches ? 'dark' : 'light'); - } else { - //console.log('System preference changed, but keeping user preference:', savedTheme); - } - }); - - // Apply the initial theme - applyTheme(_theme.darkMode); -} +// System preference changes handled centrally by themeStore now; no duplicate listeners here. diff --git a/src/lib/components/ui/base/Button.svelte b/src/lib/components/ui/base/Button.svelte index 5c28d80b..e37febd6 100644 --- a/src/lib/components/ui/base/Button.svelte +++ b/src/lib/components/ui/base/Button.svelte @@ -1,49 +1,61 @@ - {/if} {/snippet} diff --git a/src/routes/app/all-devices/+page.svelte b/src/routes/app/all-devices/+page.svelte index 469a7140..66c0a229 100644 --- a/src/routes/app/all-devices/+page.svelte +++ b/src/routes/app/all-devices/+page.svelte @@ -1,5 +1,4 @@ {#snippet triggerSnippet()} -
+
{ + if (dragEnabled && onDragOver && dragIndex !== undefined) { + e.preventDefault(); + onDragOver(e, dragIndex); + } + }} + ondrop={(e) => { + if (dragEnabled && onDrop && dragIndex !== undefined) { + e.preventDefault(); + onDrop(e, dragIndex); + } + }} + >
{ + if (dragEnabled && onDragStart && dragIndex !== undefined && e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', device.dev_eui); + onDragStart(e, dragIndex); + } + }} + ondragend={(e) => { + if (dragEnabled && onDragEnd) { + onDragEnd(e); + } + }} + title={dragEnabled ? 'Drag to reorder' : ''} >
diff --git a/src/lib/components/UI/dashboard/DeviceCard.svelte b/src/lib/components/UI/dashboard/DeviceCard.svelte index 5e00b3ee..38e5bb8d 100644 --- a/src/lib/components/UI/dashboard/DeviceCard.svelte +++ b/src/lib/components/UI/dashboard/DeviceCard.svelte @@ -16,10 +16,30 @@ }; } - let { device, isActive, locationId } = $props<{ + let { + device, + isActive, + locationId, + dragEnabled, + dragIndex, + isDragging, + isDropTarget, + onDragStart, + onDragEnd, + onDragOver, + onDrop + } = $props<{ device: DeviceWithLatestData; isActive: boolean | null; locationId: number; + dragEnabled?: boolean; + dragIndex?: number; + isDragging?: boolean; + isDropTarget?: boolean; + onDragStart?: (event: DragEvent, index: number) => void; + onDragEnd?: (event: DragEvent) => void; + onDragOver?: (event: DragEvent, index: number) => void; + onDrop?: (event: DragEvent, index: number) => void; }>(); @@ -27,6 +47,14 @@ {device} {isActive} detailHref={`/dashboard/location/${locationId}/device/${device.dev_eui}`} + {dragEnabled} + {dragIndex} + {isDragging} + {isDropTarget} + {onDragStart} + {onDragEnd} + {onDragOver} + {onDrop} > {#snippet children()} {#if !device.upload_interval === null && device.upload_interval <= 0} diff --git a/src/lib/components/UI/dashboard/DeviceCards.svelte b/src/lib/components/UI/dashboard/DeviceCards.svelte index 37aa58b5..74150c4d 100644 --- a/src/lib/components/UI/dashboard/DeviceCards.svelte +++ b/src/lib/components/UI/dashboard/DeviceCards.svelte @@ -3,109 +3,154 @@ A component that displays a grid, list, or mosaic of device cards -->
- {#each devices as device (device.dev_eui)} -
- {#if viewType === 'list'} -
selectDevice(device.dev_eui)} - class="cursor-pointer" - class:selected={selectedDevice === device.dev_eui} - > - -
- {:else} -
selectDevice(device.dev_eui)} - class="cursor-pointer" - class:selected={selectedDevice === device.dev_eui} - > - - - -
- {/if} -
- {/each} + {#each devices as device, index (device.dev_eui)} +
+ {#if viewType === 'list'} +
selectDevice(device.dev_eui)} + onkeydown={(e) => e.key === 'Enter' && selectDevice(device.dev_eui)} + class="cursor-pointer" + class:selected={selectedDevice === device.dev_eui} + role="button" + tabindex="0" + aria-label="Select device {device.name || device.dev_eui}" + > + +
+ {:else} +
selectDevice(device.dev_eui)} + onkeydown={(e) => e.key === 'Enter' && selectDevice(device.dev_eui)} + class="cursor-pointer" + class:selected={selectedDevice === device.dev_eui} + role="button" + tabindex="0" + aria-label="Select device {device.name || device.dev_eui}" + > + +
+ {/if} +
+ {/each}
diff --git a/src/lib/components/demo/DragDropDemo.svelte b/src/lib/components/demo/DragDropDemo.svelte new file mode 100644 index 00000000..c5fe9f41 --- /dev/null +++ b/src/lib/components/demo/DragDropDemo.svelte @@ -0,0 +1,137 @@ + + + +
+

Drag & Drop Demo

+ +
+ +
+ +
+

DataRowItems (drag by red handle):

+ + {#each devices as device, index (device.dev_eui)} + + {/each} +
+ +
+

Instructions:

+
    +
  • • Enable the checkbox above to activate drag & drop
  • +
  • • Click and drag the colored bar on the left of each item
  • +
  • • Drop the item at a new position to reorder
  • +
  • • Check the console for reorder events
  • +
+
+ +
+

Current Order:

+
    + {#each devices as device, index} +
  1. {index + 1}. {device.name}
  2. + {/each} +
+
+
diff --git a/src/lib/utilities/deviceOrderStorage.ts b/src/lib/utilities/deviceOrderStorage.ts new file mode 100644 index 00000000..916cabde --- /dev/null +++ b/src/lib/utilities/deviceOrderStorage.ts @@ -0,0 +1,118 @@ +/** + * Device Order Management - handles saving/loading device order from localStorage + */ + +export interface DeviceOrder { + locationId: number; + deviceOrder: string[]; // Array of dev_eui strings in order +} + +const STORAGE_KEY = 'cropwatch_device_order'; + +/** + * Save device order for a specific location to localStorage + */ +export function saveDeviceOrder(locationId: number, deviceOrder: string[]): void { + try { + if (typeof localStorage === 'undefined') return; + + const existingData = getDeviceOrderData(); + const locationIndex = existingData.findIndex((item) => item.locationId === locationId); + + const newOrderData: DeviceOrder = { locationId, deviceOrder }; + + if (locationIndex >= 0) { + // Update existing location order + existingData[locationIndex] = newOrderData; + } else { + // Add new location order + existingData.push(newOrderData); + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(existingData)); + } catch (error) { + console.warn('Failed to save device order to localStorage:', error); + } +} + +/** + * Get device order for a specific location from localStorage + */ +export function getDeviceOrder(locationId: number): string[] | null { + try { + if (typeof localStorage === 'undefined') return null; + + const data = getDeviceOrderData(); + const locationData = data.find((item) => item.locationId === locationId); + + return locationData ? locationData.deviceOrder : null; + } catch (error) { + console.warn('Failed to load device order from localStorage:', error); + return null; + } +} + +/** + * Get all device order data from localStorage + */ +function getDeviceOrderData(): DeviceOrder[] { + try { + if (typeof localStorage === 'undefined') return []; + + const data = localStorage.getItem(STORAGE_KEY); + return data ? JSON.parse(data) : []; + } catch (error) { + console.warn('Failed to parse device order data from localStorage:', error); + return []; + } +} + +/** + * Apply stored device order to an array of devices + */ +export function applyStoredDeviceOrder( + devices: T[], + locationId: number +): T[] { + const storedOrder = getDeviceOrder(locationId); + if (!storedOrder) return devices; + + // Create a map for quick lookup + const deviceMap = new Map(); + devices.forEach((device) => deviceMap.set(device.dev_eui, device)); + + // Build ordered array based on stored order + const orderedDevices: T[] = []; + const usedDevices = new Set(); + + // First, add devices in stored order + storedOrder.forEach((devEui) => { + const device = deviceMap.get(devEui); + if (device) { + orderedDevices.push(device); + usedDevices.add(devEui); + } + }); + + // Then, add any new devices that weren't in the stored order + devices.forEach((device) => { + if (!usedDevices.has(device.dev_eui)) { + orderedDevices.push(device); + } + }); + + return orderedDevices; +} + +/** + * Clear all stored device orders + */ +export function clearDeviceOrders(): void { + try { + if (typeof localStorage !== 'undefined') { + localStorage.removeItem(STORAGE_KEY); + } + } catch (error) { + console.warn('Failed to clear device orders from localStorage:', error); + } +} diff --git a/src/lib/utilities/dragAndDrop.ts b/src/lib/utilities/dragAndDrop.ts new file mode 100644 index 00000000..b9dfb7d2 --- /dev/null +++ b/src/lib/utilities/dragAndDrop.ts @@ -0,0 +1,83 @@ +/** + * Drag and Drop utilities for reordering lists + */ + +export interface DragState { + draggedIndex: number | null; + draggedItem: any | null; + dropTargetIndex: number | null; +} + +export function createDragState(): DragState { + return { + draggedIndex: null, + draggedItem: null, + dropTargetIndex: null + }; +} + +export function reorderArray(array: T[], fromIndex: number, toIndex: number): T[] { + if (fromIndex === toIndex) return array; + + const newArray = [...array]; + const item = newArray[fromIndex]; + + // Remove the item from its current position + newArray.splice(fromIndex, 1); + + // Insert the item at the new position + newArray.splice(toIndex, 0, item); + + return newArray; +} + +export function createDragHandlers( + items: T[], + onReorder: (newItems: T[]) => void, + dragState: DragState, + updateDragState: (newState: Partial) => void +) { + return { + handleDragStart: (event: DragEvent, index: number) => { + console.log('Drag start:', index, items[index]); + updateDragState({ + draggedIndex: index, + draggedItem: items[index], + dropTargetIndex: null + }); + }, + + handleDragEnd: (event: DragEvent) => { + const { draggedIndex, dropTargetIndex } = dragState; + console.log('Drag end:', { draggedIndex, dropTargetIndex }); + + if (draggedIndex !== null && dropTargetIndex !== null && draggedIndex !== dropTargetIndex) { + console.log('Reordering from', draggedIndex, 'to', dropTargetIndex); + const reorderedItems = reorderArray(items, draggedIndex, dropTargetIndex); + onReorder(reorderedItems); + } + + updateDragState({ + draggedIndex: null, + draggedItem: null, + dropTargetIndex: null + }); + }, + + handleDragOver: (event: DragEvent, index: number) => { + event.preventDefault(); + console.log('Drag over:', index); + updateDragState({ + dropTargetIndex: index + }); + }, + + handleDrop: (event: DragEvent, index: number) => { + event.preventDefault(); + console.log('Drop on:', index); + updateDragState({ + dropTargetIndex: index + }); + } + }; +} diff --git a/src/routes/app/dashboard/+page.svelte b/src/routes/app/dashboard/+page.svelte index f4ca3bb5..efa16dc2 100644 --- a/src/routes/app/dashboard/+page.svelte +++ b/src/routes/app/dashboard/+page.svelte @@ -5,6 +5,7 @@ import { getDashboardUIStore } from '$lib/stores/DashboardUIStore.svelte'; import { DeviceTimerManager } from '$lib/utilities/deviceTimerManager'; import { setupDeviceActiveTimer } from '$lib/utilities/deviceTimerSetup'; + import { applyStoredDeviceOrder } from '$lib/utilities/deviceOrderStorage'; // Get user data from the server load function let { data } = $props(); @@ -74,6 +75,17 @@ } }); + // Device reordering handler + function handleDeviceReorder(locationId: number, newDevices: DeviceWithSensorData[]) { + // Update the location's devices in the store + const location = locationsStore.locations.find((loc) => loc.location_id === locationId); + if (location) { + location.cw_devices = newDevices; + // Note: Since we're modifying the objects within the array, reactivity should work + // If needed, we could call a store method to update the locations + } + } + $effect(() => { function handleVisibilityChange() { isTabVisible = document.visibilityState === 'visible'; @@ -394,13 +406,23 @@ (loc) => loc.location_id === locationsStore.selectedLocationId )} {#if selectedLoc} - + {:else}
Selected location not found.
{/if} {:else} - + {/if} {:else}

No locations found.

diff --git a/src/routes/dragtest/+page.svelte b/src/routes/dragtest/+page.svelte new file mode 100644 index 00000000..05561339 --- /dev/null +++ b/src/routes/dragtest/+page.svelte @@ -0,0 +1,12 @@ + + + + + Drag and Drop Test + + +
+ +
From c2d03581b2b819e64d83badd9c2cf53b48afc42c Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Sun, 14 Sep 2025 14:49:54 +0900 Subject: [PATCH 05/18] detail-page date range selection box updated --- package.json | 3 +- pnpm-lock.yaml | 11 +- .../dashboard/DateRangeSelector.svelte | 237 +++++++++++------- src/lib/components/ui/base/Button.svelte | 26 +- 4 files changed, 182 insertions(+), 95 deletions(-) diff --git a/package.json b/package.json index 317c9d95..78c7d535 100644 --- a/package.json +++ b/package.json @@ -50,11 +50,11 @@ "globals": "^16.0.0", "husky": "^8.0.0", "jsdom": "^26.0.0", - "supabase": "^2.33.9", "lint-staged": "^16.1.2", "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.11", + "supabase": "^2.33.9", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "tailwindcss": "^4.0.0", @@ -65,6 +65,7 @@ "vitest": "^3.0.0" }, "dependencies": { + "@internationalized/date": "^3.9.0", "@mdi/js": "^7.4.47", "@stencil/store": "^2.1.3", "@stripe/stripe-js": "^7.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d42bd301..1acd9a08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@internationalized/date': + specifier: ^3.9.0 + version: 3.9.0 '@mdi/js': specifier: ^7.4.47 version: 7.4.47 @@ -1124,8 +1127,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@internationalized/date@3.8.2': - resolution: {integrity: sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==} + '@internationalized/date@3.9.0': + resolution: {integrity: sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg==} '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -5983,7 +5986,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@internationalized/date@3.8.2': + '@internationalized/date@3.9.0': dependencies: '@swc/helpers': 0.5.17 @@ -7341,7 +7344,7 @@ snapshots: dependencies: '@floating-ui/core': 1.7.1 '@floating-ui/dom': 1.7.1 - '@internationalized/date': 3.8.2 + '@internationalized/date': 3.9.0 css.escape: 1.5.1 esm-env: 1.2.2 runed: 0.23.4(svelte@5.34.7) diff --git a/src/lib/components/dashboard/DateRangeSelector.svelte b/src/lib/components/dashboard/DateRangeSelector.svelte index 104f5348..897dbb39 100644 --- a/src/lib/components/dashboard/DateRangeSelector.svelte +++ b/src/lib/components/dashboard/DateRangeSelector.svelte @@ -1,9 +1,11 @@
- - +
- + +
- - { - const parsed = parseInputDateLocal(startDateInputString); - - if (!parsed) { - error = 'Invalid start date format.'; - return; - } - - // Normalize start to start-of-day - parsed.setHours(0, 0, 0, 0); - startDateInput = parsed; - - // If start date is after end date, align end to end-of-day of start - if (startDateInput > endDateInput) { - const end = new Date(startDateInput); - end.setHours(23, 59, 59, 999); - endDateInput = end; - } + + - handleDateChange(); - }} - max={endDateInputString} - class="relative flex w-full rounded border border-gray-300 bg-white px-2 py-2 pr-10 text-sm text-xl text-gray-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-white" - /> -
-
- - { - const parsed = parseInputDateLocal(endDateInputString); - if (!parsed) { - return; - } - // Normalize end to end-of-day - parsed.setHours(23, 59, 59, 999); - - // Cap to today end-of-day - const todayEnd = new Date(); - todayEnd.setHours(23, 59, 59, 999); - endDateInput = parsed > todayEnd ? todayEnd : parsed; - - // If end before start, align start to start-of-day of end - if (endDateInput < startDateInput) { - const start = new Date(endDateInput); - start.setHours(0, 0, 0, 0); - startDateInput = start; - } +
+ {#each ['start', 'end'] as const as type (type)} + + {#snippet children({ segments })} + {#each segments as { part, value }, i (part + i)} +
+ {#if part === 'literal'} + + {value} + + {:else} + + {value} + + {/if} +
+ {/each} + {/snippet} +
+ {#if type === 'start'} + + {/if} + {/each} - handleDateChange(); - }} - min={startDateInputString} - max={todayString} - class="relative flex w-full rounded border border-gray-300 bg-white px-2 py-2 pr-10 text-sm text-xl text-gray-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-white" - /> + + + +
+ + + + {#snippet children({ months, weekdays })} + + + + + + + + + + +
+ {#each months as month (month.value)} + + + + {#each weekdays as day (day)} + +
{day.slice(0, 2)}
+
+ {/each} +
+
+ + {#each month.weeks as weekDates (weekDates)} + + {#each weekDates as date (date)} + + + {date.day} + + + {/each} + + {/each} + +
+ {/each} +
+ {/snippet} +
+
+
+
+ {#if error} -

{error}

+

{error}

{/if} -
diff --git a/src/lib/components/ui/base/Button.svelte b/src/lib/components/ui/base/Button.svelte index e37febd6..1d89f6ed 100644 --- a/src/lib/components/ui/base/Button.svelte +++ b/src/lib/components/ui/base/Button.svelte @@ -12,6 +12,7 @@ icon = undefined, type = 'button', className = '', + loading = false, disabled = false, children } = $props<{ @@ -21,6 +22,7 @@ icon?: string; type?: 'button' | 'submit'; className?: string; + loading?: boolean; disabled?: boolean; children?: Snippet; }>(); @@ -55,11 +57,29 @@ } - From bdad04c45f311d96c5fba14c75a2bef59abe0564 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Sun, 14 Sep 2025 17:00:20 +0900 Subject: [PATCH 06/18] traffic export working --- src/lib/interfaces/IDeviceDataService.ts | 29 ++- src/lib/services/DeviceDataService.ts | 171 ++++++++++++++++-- .../api/devices/[devEui]/csv/+server.ts | 24 ++- .../api/devices/[devEui]/data-jwt/+server.ts | 25 ++- .../api/devices/[devEui]/data/+server.ts | 26 ++- .../api/devices/[devEui]/pdf/+server.ts | 22 ++- .../devices/[devEui]/+page.server.ts | 11 +- 7 files changed, 259 insertions(+), 49 deletions(-) diff --git a/src/lib/interfaces/IDeviceDataService.ts b/src/lib/interfaces/IDeviceDataService.ts index 6dcad329..acb6e71d 100644 --- a/src/lib/interfaces/IDeviceDataService.ts +++ b/src/lib/interfaces/IDeviceDataService.ts @@ -1,3 +1,4 @@ +import type { ReportAlertPoint } from '../models/Report'; import type { DeviceType } from '../models/Device'; import type { DeviceDataRecord } from '../models/DeviceDataRecord'; @@ -8,21 +9,35 @@ export interface IDeviceDataService { /** * Get the latest data for a device based on its type * @param devEui The device EUI - * @param deviceType The device type information containing data_table_v2 */ - getLatestDeviceData(devEui: string, deviceType: DeviceType): Promise; + getLatestDeviceData(devEui: string): Promise; /** * Get device data within a date range based on device type * @param devEui The device EUI - * @param deviceType The device type information containing data_table_v2 - * @param startDate The start date - * @param endDate The end date + * @param startDate The start date in user's timezone + * @param endDate The end date in user's timezone + * @param timezone The user's timezone (e.g., 'Asia/Tokyo', 'America/New_York') */ getDeviceDataByDateRange( devEui: string, startDate: Date, - endDate: Date + endDate: Date, + timezone?: string + ): Promise; + + /** + * Get device data within a date range based on device type as CSV + * @param devEui The device EUI + * @param startDate The start date in user's timezone + * @param endDate The end date in user's timezone + * @param timezone The user's timezone (e.g., 'Asia/Tokyo', 'America/New_York') + */ + getDeviceDataByDateRangeAsCSV( + devEui: string, + startDate: Date, + endDate: Date, + timezone?: string ): Promise; /** @@ -63,5 +78,5 @@ export interface IDeviceDataService { * Get alert points for a device from its reports * @param devEui The device EUI */ - getAlertPointsForDevice(devEui: string): Promise; + getAlertPointsForDevice(devEui: string): Promise; } diff --git a/src/lib/services/DeviceDataService.ts b/src/lib/services/DeviceDataService.ts index abca9df2..71f509d5 100644 --- a/src/lib/services/DeviceDataService.ts +++ b/src/lib/services/DeviceDataService.ts @@ -64,13 +64,15 @@ export class DeviceDataService implements IDeviceDataService { * Get device data within a date range based on device type * @param devEui The device EUI * @param deviceType The device type information containing data_table_v2 - * @param startDate The start date - * @param endDate The end date + * @param startDate The start date in user's timezone + * @param endDate The end date in user's timezone + * @param timezone The user's timezone (e.g., 'Asia/Tokyo', 'America/New_York') */ public async getDeviceDataByDateRange( devEui: string, startDate: Date, - endDate: Date + endDate: Date, + timezone: string = 'UTC' ): Promise { if (!devEui) { throw new Error('Device EUI not specified'); @@ -88,6 +90,10 @@ export class DeviceDataService implements IDeviceDataService { const cw_device = await this.getDeviceAndType(devEui); const tableName = cw_device.cw_device_type.data_table_v2; // Pull out the table name + // Convert user timezone dates to UTC for database queries + const utcStartDate = this.convertUserTimezoneToUTC(startDate, timezone); + const utcEndDate = this.convertUserTimezoneToUTC(endDate, timezone); + try { // get the number of uploads between the selected start and end dates const monthsInRange = Math.floor( @@ -102,23 +108,28 @@ export class DeviceDataService implements IDeviceDataService { .from(tableName) .select('*') .eq('dev_eui', devEui) - .gte(tableName == 'cw_traffic2' ? 'traffic_hour' : 'created_at', startDate.toISOString()) // SHIT FIX #1, traffic camera specific - .lte(tableName == 'cw_traffic2' ? 'traffic_hour' : 'created_at', endDate.toISOString()) // SHIT FIX #2, traffic camera specific + .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') { - return (data || []).map((record: any) => ({ + 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); } // END OF SHIT FIX - return (data || []) as DeviceDataRecord[]; + const records = (data || []) as DeviceDataRecord[]; + // Convert timestamps back to user timezone + return this.convertRecordTimestampsToUserTimezone(records, timezone, tableName); } catch (error) { // Handle errors with a generic response this.errorHandler.logError(error as Error); @@ -144,13 +155,15 @@ export class DeviceDataService implements IDeviceDataService { * Get device data within a date range based on device type as a CSV * @param devEui The device EUI * @param deviceType The device type information containing data_table_v2 - * @param startDate The start date - * @param endDate The end date + * @param startDate The start date in user's timezone + * @param endDate The end date in user's timezone + * @param timezone The user's timezone (e.g., 'Asia/Tokyo', 'America/New_York') */ public async getDeviceDataByDateRangeAsCSV( devEui: string, startDate: Date, - endDate: Date + endDate: Date, + timezone: string = 'UTC' ): Promise { if (!devEui) { throw new Error('Device EUI not specified'); @@ -168,6 +181,10 @@ export class DeviceDataService implements IDeviceDataService { const cw_device = await this.getDeviceAndType(devEui); const tableName = cw_device.cw_device_type.data_table_v2; // Pull out the table name + // Convert user timezone dates to UTC for database queries + const utcStartDate = this.convertUserTimezoneToUTC(startDate, timezone); + const utcEndDate = this.convertUserTimezoneToUTC(endDate, timezone); + try { // get the number of uploads between the selected start and end dates const monthsInRange = Math.floor( @@ -182,8 +199,8 @@ export class DeviceDataService implements IDeviceDataService { .from(tableName) .select('*') .eq('dev_eui', devEui) - .gte(tableName == 'cw_traffic2' ? 'traffic_hour' : 'created_at', startDate.toISOString()) // SHIT FIX #1, traffic camera specific - .lte(tableName == 'cw_traffic2' ? 'traffic_hour' : 'created_at', endDate.toISOString()) // SHIT FIX #2, traffic camera specific + .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 if (error) { @@ -209,9 +226,17 @@ export class DeviceDataService implements IDeviceDataService { if (!dt.isValid) dt = DateTime.fromSQL(String(raw), { setZone: true }); if (dt.isValid) { // Format for Microsoft Excel compatibility: "yyyy-MM-dd HH:mm:ss" (no T, no timezone) - const excelLocal = dt.setZone('Asia/Tokyo').toFormat('yyyy-LL-dd HH:mm:ss'); - // Standardize on created_at in the CSV - row.created_at = excelLocal; + const excelLocal = dt.setZone(timezone).toFormat('yyyy-LL-dd HH:mm:ss'); + + // For traffic data, update both created_at and traffic_hour to maintain consistency + if (tableName === 'cw_traffic2') { + row.created_at = excelLocal; + // For CSV consistency, also format traffic_hour in the same Excel-friendly format + row.traffic_hour = excelLocal; + } else { + // For other data types, standardize on created_at + row.created_at = excelLocal; + } } } @@ -317,7 +342,7 @@ export class DeviceDataService implements IDeviceDataService { try { // If no filtering parameters provided, get alert points from the device's reports - let p_columns = columns.length === 0 ? columns : ['temperature_c', 'humidity']; + let p_columns = !columns || columns.length === 0 ? ['temperature_c', 'humidity'] : columns; let p_ops = ops || ['>', 'BETWEEN']; let p_mins = mins || [25.0, 55.0]; let p_maxs = maxs || [null, 65.0]; @@ -342,7 +367,9 @@ export class DeviceDataService implements IDeviceDataService { alertPoints.forEach((point: any) => { if (point.data_point_key) { - p_columns.push(point.data_point_key); + if (p_columns && !p_columns.includes(point.data_point_key)) { + p_columns.push(point.data_point_key); + } p_ops.push(point.operator || '>'); p_mins.push(point.min || point.value || 0); p_maxs.push(point.max || null); @@ -457,6 +484,116 @@ export class DeviceDataService implements IDeviceDataService { return true; // Default to true for now } + /** + * Convert user timezone dates to UTC for database queries + * @param date Date in user's timezone + * @param timezone User's timezone string + * @returns Date converted to UTC + */ + private convertUserTimezoneToUTC(date: Date, timezone: string): Date { + if (timezone === 'UTC') { + return date; // No conversion needed + } + + // Create DateTime from the date's components in the specified timezone + const year = date.getFullYear(); + const month = date.getMonth() + 1; // getMonth() returns 0-11, DateTime expects 1-12 + const day = date.getDate(); + const hour = date.getHours(); + const minute = date.getMinutes(); + const second = date.getSeconds(); + + // Create DateTime in the user's timezone, then convert to UTC + const dt = DateTime.fromObject( + { + year, + month, + day, + hour, + minute, + second + }, + { zone: timezone } + ); + + return dt.toUTC().toJSDate(); + } + + /** + * Convert UTC timestamp back to user's timezone + * @param utcTimestamp UTC timestamp string + * @param timezone User's timezone string + * @returns Formatted timestamp in user's timezone + */ + private convertUTCToUserTimezone(utcTimestamp: string, timezone: string): string { + if (timezone === 'UTC') { + return utcTimestamp; // No conversion needed + } + + // Parse UTC timestamp and convert to user timezone + let dt = DateTime.fromISO(utcTimestamp, { zone: 'UTC' }); + + if (!dt.isValid) { + // Try parsing as SQL format if ISO fails + dt = DateTime.fromSQL(utcTimestamp, { zone: 'UTC' }); + if (!dt.isValid) { + console.warn(`Failed to parse timestamp: ${utcTimestamp}`); + return utcTimestamp; // Return original if parsing fails + } + } + + // Convert to user timezone and return as ISO string + const converted = dt.setZone(timezone); + const result = converted.toISO(); + + if (!result) { + console.warn(`Failed to convert timestamp to timezone ${timezone}: ${utcTimestamp}`); + return utcTimestamp; + } + + return result; + } + + /** + * Convert all timestamps in device data records from UTC to user timezone + * @param records Array of device data records + * @param timezone User's timezone string + * @param tableName Table name to determine timestamp field + * @returns Records with timestamps converted to user timezone + */ + private convertRecordTimestampsToUserTimezone( + records: DeviceDataRecord[], + timezone: string, + tableName: string + ): DeviceDataRecord[] { + if (timezone === 'UTC') { + return records; // No conversion needed + } + + return records.map((record) => { + const convertedRecord = { ...record }; + + // Convert main timestamp field + const timestampField = tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at'; + if (convertedRecord[timestampField]) { + convertedRecord[timestampField] = this.convertUTCToUserTimezone( + convertedRecord[timestampField] as string, + timezone + ); + } + + // Ensure created_at is always present and converted + if (convertedRecord.created_at) { + convertedRecord.created_at = this.convertUTCToUserTimezone( + convertedRecord.created_at, + timezone + ); + } + + return convertedRecord; + }); + } + private toCSV(records: Record[], headers?: string[]): string { if (!records || records.length === 0) { return headers && headers.length ? headers.join(',') + '\n' : ''; diff --git a/src/routes/api/devices/[devEui]/csv/+server.ts b/src/routes/api/devices/[devEui]/csv/+server.ts index 0618be97..3dddaf82 100644 --- a/src/routes/api/devices/[devEui]/csv/+server.ts +++ b/src/routes/api/devices/[devEui]/csv/+server.ts @@ -18,6 +18,7 @@ export const GET: RequestHandler = async ({ // Get query parameters for date range const startDateParam = url.searchParams.get('start'); const endDateParam = url.searchParams.get('end'); + const timezoneParam = url.searchParams.get('timezone') || 'UTC'; if (!startDateParam || !endDateParam) { throw error(400, 'Start and end dates are required'); @@ -31,9 +32,19 @@ export const GET: RequestHandler = async ({ throw error(400, 'Invalid date format'); } - // include the full day in the results - startDate = DateTime.fromJSDate(startDate).startOf('day').toJSDate(); - endDate = DateTime.fromJSDate(endDate).endOf('day').toJSDate(); + // Include the full day in the results, but do this in the user's timezone + // Parse dates as local dates in the user's timezone, then set to start/end of day + const userTimezone = timezoneParam; + + // Create DateTime objects from the date strings in the user's timezone + const startDt = DateTime.fromISO(startDateParam + 'T00:00:00', { zone: userTimezone }).startOf( + 'day' + ); + const endDt = DateTime.fromISO(endDateParam + 'T23:59:59', { zone: userTimezone }).endOf('day'); + + // Convert back to JavaScript Date objects + startDate = startDt.toJSDate(); + endDate = endDt.toJSDate(); // Get services from the container const deviceDataService = new DeviceDataService(supabase); @@ -44,7 +55,12 @@ export const GET: RequestHandler = async ({ // Try to get data dynamically based on device type try { - csvData = await deviceDataService.getDeviceDataByDateRangeAsCSV(devEui, startDate, endDate); + csvData = await deviceDataService.getDeviceDataByDateRangeAsCSV( + devEui, + startDate, + endDate, + timezoneParam + ); if (!csvData || csvData.length === 0) { throw new Error('No data found for the specified date range'); } diff --git a/src/routes/api/devices/[devEui]/data-jwt/+server.ts b/src/routes/api/devices/[devEui]/data-jwt/+server.ts index 41c797c4..7f65e5fe 100644 --- a/src/routes/api/devices/[devEui]/data-jwt/+server.ts +++ b/src/routes/api/devices/[devEui]/data-jwt/+server.ts @@ -84,6 +84,7 @@ export const GET: RequestHandler = async ({ params, url, request, locals: { supa // Get query parameters for date range const startDateParam = url.searchParams.get('start'); const endDateParam = url.searchParams.get('end'); + const timezoneParam = url.searchParams.get('timezone') || 'UTC'; if (!startDateParam || !endDateParam) { throw error( @@ -103,9 +104,18 @@ export const GET: RequestHandler = async ({ params, url, request, locals: { supa ); } - // include the full day in the results - startDate = DateTime.fromJSDate(startDate).startOf('day').toJSDate(); - endDate = DateTime.fromJSDate(endDate).endOf('day').toJSDate(); + // Include the full day in the results, but do this in the user's timezone + const userTimezone = timezoneParam; + + // Create DateTime objects from the date strings in the user's timezone + const startDt = DateTime.fromISO(startDateParam + 'T00:00:00', { zone: userTimezone }).startOf( + 'day' + ); + const endDt = DateTime.fromISO(endDateParam + 'T23:59:59', { zone: userTimezone }).endOf('day'); + + // Convert back to JavaScript Date objects + startDate = startDt.toJSDate(); + endDate = endDt.toJSDate(); //console.log( // `[JWT API] Fetching data for device ${devEui} from ${startDate.toISOString()} to ${endDate.toISOString()}` @@ -119,8 +129,13 @@ export const GET: RequestHandler = async ({ params, url, request, locals: { supa //console.log('Using JWT-authenticated Supabase client for data queries...'); // First attempt: Use DeviceDataService with JWT-authenticated client - const deviceDataService = new DeviceDataService(jwtSupabase, undefined, jwt); - historicalData = await deviceDataService.getDeviceDataByDateRange(devEui, startDate, endDate); + const deviceDataService = new DeviceDataService(jwtSupabase); + historicalData = await deviceDataService.getDeviceDataByDateRange( + devEui, + startDate, + endDate, + timezoneParam + ); if (historicalData && historicalData.length > 0) { //console.log( diff --git a/src/routes/api/devices/[devEui]/data/+server.ts b/src/routes/api/devices/[devEui]/data/+server.ts index 7a5bb5a1..cfc73afb 100644 --- a/src/routes/api/devices/[devEui]/data/+server.ts +++ b/src/routes/api/devices/[devEui]/data/+server.ts @@ -1,6 +1,7 @@ import { error, json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { DeviceDataService } from '$lib/services/DeviceDataService'; +import type { DeviceDataRecord } from '$lib/models/DeviceDataRecord'; import { DateTime } from 'luxon'; export const GET: RequestHandler = async ({ @@ -18,6 +19,7 @@ export const GET: RequestHandler = async ({ // Get query parameters for date range const startDateParam = url.searchParams.get('start'); const endDateParam = url.searchParams.get('end'); + const timezoneParam = url.searchParams.get('timezone') || 'UTC'; if (!startDateParam || !endDateParam) { throw error(400, 'Start and end dates are required'); @@ -31,9 +33,18 @@ export const GET: RequestHandler = async ({ throw error(400, 'Invalid date format'); } - // include the full day in the results - startDate = DateTime.fromJSDate(startDate).startOf('day').toJSDate(); - endDate = DateTime.fromJSDate(endDate).endOf('day').toJSDate(); + // Include the full day in the results, but do this in the user's timezone + const userTimezone = timezoneParam; + + // Create DateTime objects from the date strings in the user's timezone + const startDt = DateTime.fromISO(startDateParam + 'T00:00:00', { zone: userTimezone }).startOf( + 'day' + ); + const endDt = DateTime.fromISO(endDateParam + 'T23:59:59', { zone: userTimezone }).endOf('day'); + + // Convert back to JavaScript Date objects + startDate = startDt.toJSDate(); + endDate = endDt.toJSDate(); // Get services from the container const deviceDataService = new DeviceDataService(supabase); @@ -53,9 +64,14 @@ export const GET: RequestHandler = async ({ // Will fall back to specific services below } - let historicalData = []; + let historicalData: DeviceDataRecord[] = []; try { - historicalData = await deviceDataService.getDeviceDataByDateRange(devEui, startDate, endDate); + historicalData = await deviceDataService.getDeviceDataByDateRange( + devEui, + startDate, + endDate, + timezoneParam + ); if (!historicalData || historicalData.length === 0) { throw new Error('No historical data found for the specified date range'); } diff --git a/src/routes/api/devices/[devEui]/pdf/+server.ts b/src/routes/api/devices/[devEui]/pdf/+server.ts index 00ac2959..fa71cb99 100644 --- a/src/routes/api/devices/[devEui]/pdf/+server.ts +++ b/src/routes/api/devices/[devEui]/pdf/+server.ts @@ -65,7 +65,8 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) end: endDateParam, dataKeys: dataKeysParam = '', alertPoints: alertPointsParam, - locale: localeParam = 'ja' + locale: localeParam = 'ja', + timezone: timezoneParam = 'Asia/Tokyo' } = Object.fromEntries(url.searchParams); const selectedKeys = dataKeysParam @@ -156,13 +157,13 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) ); } - // Convert dates to Japan timezone and include full day - const tokyoStartDate = DateTime.fromJSDate(startDate).setZone('Asia/Tokyo').startOf('day'); - const tokyoEndDate = DateTime.fromJSDate(endDate).setZone('Asia/Tokyo').endOf('day'); + // Convert dates to user timezone and include full day + const userStartDate = DateTime.fromJSDate(startDate).setZone(timezoneParam).startOf('day'); + const userEndDate = DateTime.fromJSDate(endDate).setZone(timezoneParam).endOf('day'); // Convert back to UTC for database queries - startDate = tokyoStartDate.toUTC().toJSDate(); - endDate = tokyoEndDate.toUTC().toJSDate(); + startDate = userStartDate.toUTC().toJSDate(); + endDate = userEndDate.toUTC().toJSDate(); // Get device data using JWT-authenticated client (same method as browser version) const deviceDataService = new DeviceDataService(supabase); @@ -198,14 +199,19 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) devEui, startDate, endDate, - timezone: 'Asia/Tokyo', + timezone: timezoneParam, columns: requestedAlertPoints.map((point) => point.data_point_key as string), ops: requestedAlertPoints.map((point) => point.operator as string), mins: requestedAlertPoints.map((point) => (point.min ?? point.value) as number), maxs: requestedAlertPoints.map((point) => point.max ?? null), intervalMinutes: 30 // Default interval in minutes }) - : await deviceDataService.getDeviceDataByDateRange(devEui, startDate, endDate); + : await deviceDataService.getDeviceDataByDateRange( + devEui, + startDate, + endDate, + timezoneParam + ); if (deviceDataResponse && deviceDataResponse.length > 0) { deviceData = deviceDataResponse; 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 5a10675a..26391b3c 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 @@ -15,8 +15,8 @@ export const load: PageServerLoad = async ({ url, params, locals: { supabase } } const startParam = url.searchParams.get('start'); const endParam = url.searchParams.get('end'); - let startDate = url.searchParams.get('start'); - let endDate = url.searchParams.get('end'); + const timezoneParam = url.searchParams.get('timezone') || 'UTC'; + let startDate: Date, endDate: Date; if (!startParam || !endParam) { startDate = DateTime.now().minus({ days: 1 }).startOf('day').toJSDate(); endDate = DateTime.now().endOf('day').toJSDate(); @@ -27,7 +27,12 @@ export const load: PageServerLoad = async ({ url, params, locals: { supabase } } // Don’t `await` the promise, stream the data instead const latestData = deviceDataService.getLatestDeviceData(devEui); - const historicalData = deviceDataService.getDeviceDataByDateRange(devEui, startDate, endDate); + const historicalData = deviceDataService.getDeviceDataByDateRange( + devEui, + startDate, + endDate, + timezoneParam + ); return { latestData, // streamed From d4deef3be2a0f7b058bd437372076aea184faf17 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Sun, 14 Sep 2025 17:13:14 +0900 Subject: [PATCH 07/18] added unit tests for this important data timezone bs --- src/lib/tests/CSVExportValidation.test.ts | 348 ++++++++++++++++++ .../tests/DeviceDataService.timezone.test.ts | 300 +++++++++++++++ src/lib/tests/README.md | 99 +++++ src/lib/tests/TrafficDataIntegration.test.ts | 273 ++++++++++++++ test-timezone-critical.sh | 69 ++++ 5 files changed, 1089 insertions(+) create mode 100644 src/lib/tests/CSVExportValidation.test.ts create mode 100644 src/lib/tests/DeviceDataService.timezone.test.ts create mode 100644 src/lib/tests/README.md create mode 100644 src/lib/tests/TrafficDataIntegration.test.ts create mode 100755 test-timezone-critical.sh diff --git a/src/lib/tests/CSVExportValidation.test.ts b/src/lib/tests/CSVExportValidation.test.ts new file mode 100644 index 00000000..eb21dfb3 --- /dev/null +++ b/src/lib/tests/CSVExportValidation.test.ts @@ -0,0 +1,348 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { DateTime } from 'luxon'; + +/** + * CSV EXPORT VALIDATION TESTS + * + * These tests validate that CSV exports show realistic traffic patterns + * and proper timezone formatting. This prevents regression of the + * "very very backwards" traffic pattern bug. + */ + +describe('CSV Export Validation Tests', () => { + const baseUrl = 'http://localhost:5173'; + const devEui = '110110145241600107'; + const timezone = 'Asia/Tokyo'; + + // Helper to make authenticated requests (you may need to adjust based on your auth) + async function fetchCSV(startDate: string, endDate: string, timezone: string) { + const url = `${baseUrl}/api/devices/${devEui}/csv?startDate=${startDate}&endDate=${endDate}&timezone=${timezone}`; + + try { + const response = await fetch(url, { + headers: { + Accept: 'text/csv' + // Add authentication headers if needed + // 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.text(); + } catch (error) { + // If server is not running, skip the test + if (error instanceof Error && error.message.includes('ECONNREFUSED')) { + console.warn('⚠️ Development server not running, skipping CSV API test'); + return null; + } + throw error; + } + } + + describe('CSV Format and Timezone Validation', () => { + it('should generate CSV with proper timezone formatting for August 1st', async () => { + // Arrange + const startDate = '2025-08-01'; + const endDate = '2025-08-01'; + + // Act + const csvData = await fetchCSV(startDate, endDate, timezone); + + // Skip test if server not running + if (csvData === null) return; + + // Assert + expect(csvData).toBeDefined(); + expect(typeof csvData).toBe('string'); + + // Should contain CSV headers + expect(csvData).toContain('traffic_hour'); + expect(csvData).toContain('people_count'); + expect(csvData).toContain('car_count'); + + // Should contain August 1st data + expect(csvData).toContain('2025-08-01'); + + // Should NOT contain the backwards pattern indicators + // (i.e., should not have high traffic at very early morning hours) + const lines = csvData.split('\n'); + const dataLines = lines.slice(1).filter((line) => line.trim()); // Skip header + + if (dataLines.length > 0) { + // Parse a few data rows to check timezone formatting + const sampleRow = dataLines[0].split(','); + const timestampColumn = sampleRow[0]; // Assuming first column is timestamp + + // Should be in proper ISO format with timezone + expect(timestampColumn).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + + console.log(`✅ VERIFIED: CSV contains properly formatted timestamps: ${timestampColumn}`); + } + }, 10000); + + it('should show realistic traffic patterns in CSV export', async () => { + // Arrange - Get a sample day + const startDate = '2025-08-15'; + const endDate = '2025-08-15'; + + // Act + const csvData = await fetchCSV(startDate, endDate, timezone); + + // Skip test if server not running + if (csvData === null) return; + + // Assert + expect(csvData).toBeDefined(); + + const lines = csvData.split('\n'); + const headerLine = lines[0]; + const dataLines = lines.slice(1).filter((line) => line.trim()); + + if (dataLines.length === 0) { + console.warn('⚠️ No data found for test date, skipping pattern validation'); + return; + } + + // Find column indices + const headers = headerLine.split(','); + const timestampIndex = headers.findIndex( + (h) => h.includes('traffic_hour') || h.includes('timestamp') + ); + const peopleIndex = headers.findIndex((h) => h.includes('people_count')); + const carIndex = headers.findIndex((h) => h.includes('car_count')); + + expect(timestampIndex).toBeGreaterThanOrEqual(0); + expect(peopleIndex).toBeGreaterThanOrEqual(0); + expect(carIndex).toBeGreaterThanOrEqual(0); + + // Parse data and categorize by hour + const hourlyData: { [hour: number]: { people: number; cars: number; count: number } } = {}; + + dataLines.forEach((line) => { + const columns = line.split(','); + const timestamp = columns[timestampIndex]; + const people = parseInt(columns[peopleIndex]) || 0; + const cars = parseInt(columns[carIndex]) || 0; + + // Parse timestamp to get hour in Tokyo timezone + const dt = DateTime.fromISO(timestamp).setZone(timezone); + const hour = dt.hour; + + if (!hourlyData[hour]) { + hourlyData[hour] = { people: 0, cars: 0, count: 0 }; + } + + hourlyData[hour].people += people; + hourlyData[hour].cars += cars; + hourlyData[hour].count += 1; + }); + + // Calculate averages for night vs day + let nightTraffic = 0, + dayTraffic = 0; + let nightCount = 0, + dayCount = 0; + + Object.entries(hourlyData).forEach(([hourStr, data]) => { + const hour = parseInt(hourStr); + const avgTraffic = (data.people + data.cars) / data.count; + + if (hour >= 0 && hour <= 5) { + // Night hours + nightTraffic += avgTraffic; + nightCount++; + } else if (hour >= 9 && hour <= 17) { + // Day hours + dayTraffic += avgTraffic; + dayCount++; + } + }); + + if (nightCount > 0 && dayCount > 0) { + const avgNightTraffic = nightTraffic / nightCount; + const avgDayTraffic = dayTraffic / dayCount; + + // Realistic pattern: day should have more traffic than night + expect(avgDayTraffic).toBeGreaterThanOrEqual(avgNightTraffic); + + console.log( + `✅ VERIFIED: Realistic CSV traffic pattern - Avg Day: ${avgDayTraffic.toFixed(2)}, Avg Night: ${avgNightTraffic.toFixed(2)}` + ); + } + }, 15000); + + it('should maintain consistent timezone formatting across full month CSV', async () => { + // Arrange - Test full August month + const startDate = '2025-08-01'; + const endDate = '2025-08-31'; + + // Act + const csvData = await fetchCSV(startDate, endDate, timezone); + + // Skip test if server not running + if (csvData === null) return; + + // Assert + expect(csvData).toBeDefined(); + + const lines = csvData.split('\n'); + const dataLines = lines.slice(1).filter((line) => line.trim()); + + if (dataLines.length === 0) { + console.warn('⚠️ No data found for full month, skipping consistency test'); + return; + } + + // Check that we have a reasonable amount of data + expect(dataLines.length).toBeGreaterThan(500); // Should be ~744 for full month + + // Sample some timestamps to verify consistency + const sampleIndices = [ + 0, + Math.floor(dataLines.length / 4), + Math.floor(dataLines.length / 2), + dataLines.length - 1 + ]; + + sampleIndices.forEach((index) => { + if (index < dataLines.length) { + const line = dataLines[index]; + const timestamp = line.split(',')[0]; + + // Should be properly formatted ISO timestamp + expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + + // Should be within August 2025 range + expect(timestamp).toMatch(/^2025-08/); + } + }); + + console.log( + `✅ VERIFIED: Consistent timezone formatting across ${dataLines.length} CSV records` + ); + }, 20000); + }); + + describe('CSV Content Validation', () => { + it('should include the critical record (ID 35976) in August 1st CSV', async () => { + // Arrange + const startDate = '2025-08-01'; + const endDate = '2025-08-01'; + + // Act + const csvData = await fetchCSV(startDate, endDate, timezone); + + // Skip test if server not running + if (csvData === null) return; + + // Assert + expect(csvData).toBeDefined(); + + // Should contain August 1st midnight data + // The exact time representation might vary based on CSV formatting, + // but it should include the midnight hour + expect(csvData).toContain('2025-08-01'); + + // Should have multiple records for the day (24 hours worth) + const lines = csvData.split('\n'); + const dataLines = lines.slice(1).filter((line) => line.trim()); + expect(dataLines.length).toBeGreaterThan(10); // At least some data for the day + + console.log(`✅ VERIFIED: August 1st CSV contains ${dataLines.length} records`); + }, 10000); + + it('should validate CSV structure and completeness', async () => { + // Arrange + const startDate = '2025-08-10'; // Use a mid-month date for stability + const endDate = '2025-08-10'; + + // Act + const csvData = await fetchCSV(startDate, endDate, timezone); + + // Skip test if server not running + if (csvData === null) return; + + // Assert + const lines = csvData.split('\n'); + expect(lines.length).toBeGreaterThan(1); // Header + at least some data + + const headerLine = lines[0]; + const requiredColumns = [ + 'traffic_hour', + 'people_count', + 'car_count', + 'truck_count', + 'bicycle_count' + ]; + + requiredColumns.forEach((column) => { + expect(headerLine.toLowerCase()).toContain(column.toLowerCase()); + }); + + // Validate data rows have same number of columns as header + const headerColumnCount = headerLine.split(',').length; + const dataLines = lines.slice(1).filter((line) => line.trim()); + + dataLines.slice(0, 5).forEach((line) => { + // Check first few rows + const columnCount = line.split(',').length; + expect(columnCount).toBe(headerColumnCount); + }); + + console.log(`✅ VERIFIED: CSV structure is valid with ${headerColumnCount} columns`); + }, 10000); + }); +}); + +/** + * MOCK CSV TESTS (for when server is not running) + * + * These tests can run without a live server and validate the CSV generation logic. + */ +describe('CSV Generation Logic Tests', () => { + it('should format timestamps correctly for Tokyo timezone', () => { + // Arrange + const utcTimestamp = '2025-07-31T15:00:00.000Z'; // July 31st 3PM UTC + const timezone = 'Asia/Tokyo'; + + // Act - Convert UTC to Tokyo time (same logic as CSV generation) + const dt = DateTime.fromISO(utcTimestamp, { zone: 'UTC' }); + const tokyoTime = dt.setZone(timezone); + const formattedTime = tokyoTime.toISO(); + + // Assert - Should be August 1st midnight Tokyo + expect(formattedTime).toContain('2025-08-01T00:00:00'); + expect(formattedTime).toContain('+09:00'); // Tokyo timezone offset + }); + + it('should validate traffic_hour field handling for CSV', () => { + // Arrange - Sample traffic data record + const trafficRecord = { + id: 35976, + traffic_hour: '2025-07-31T15:00:00+00:00', + people_count: 0, + car_count: 6, + truck_count: 1, + bicycle_count: 0, + bus_count: 0 + }; + + // Act - Convert to Tokyo timezone for CSV + const dt = DateTime.fromISO(trafficRecord.traffic_hour); + const tokyoTime = dt.setZone('Asia/Tokyo'); + + // Assert + expect(tokyoTime.year).toBe(2025); + expect(tokyoTime.month).toBe(8); // August + expect(tokyoTime.day).toBe(1); + expect(tokyoTime.hour).toBe(0); // Midnight + + // Should be formatted for CSV + const csvTimestamp = tokyoTime.toISO(); + expect(csvTimestamp).toBeDefined(); + expect(csvTimestamp).toContain('2025-08-01T00:00:00'); + }); +}); diff --git a/src/lib/tests/DeviceDataService.timezone.test.ts b/src/lib/tests/DeviceDataService.timezone.test.ts new file mode 100644 index 00000000..577d46ef --- /dev/null +++ b/src/lib/tests/DeviceDataService.timezone.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { DateTime } from 'luxon'; +import { DeviceDataService } from '../services/DeviceDataService'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +/** + * CRITICAL TIMEZONE CONVERSION TESTS + * + * These tests ensure that the timezone conversion logic that fixes the + * "very very backwards" traffic patterns continues to work correctly. + * + * Key requirement: August 1st midnight Tokyo should convert to July 31st 3PM UTC + * This specific conversion was verified to return record ID 35976 in production. + */ + +describe('DeviceDataService - Critical Timezone Conversions', () => { + let deviceDataService: DeviceDataService; + let mockSupabase: SupabaseClient; + + beforeEach(() => { + // Mock Supabase client + mockSupabase = { + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { + cw_device_type: { data_table_v2: 'cw_traffic2' } + }, + error: null + }) + }) + }) + }) + } as any; + + deviceDataService = new DeviceDataService(mockSupabase); + }); + + describe('convertUserTimezoneToUTC', () => { + it('should handle UTC timezone (no conversion)', () => { + // Arrange + const date = new Date('2025-08-01T00:00:00.000Z'); + const timezone = 'UTC'; + + // Act + const result = (deviceDataService as any).convertUserTimezoneToUTC(date, timezone); + + // Assert + expect(result).toEqual(date); + }); + + it('should convert August 1st midnight Tokyo to July 31st 3PM UTC (CRITICAL TEST)', () => { + // Arrange - This is the exact conversion that was verified to work + const tokyoMidnight = new Date(2025, 7, 1, 0, 0, 0, 0); // Month is 0-indexed (7 = August) + const timezone = 'Asia/Tokyo'; + + // Act + const result = (deviceDataService as any).convertUserTimezoneToUTC(tokyoMidnight, timezone); + + // Assert - Must convert to July 31st 15:00 UTC (3PM) + expect(result.getUTCFullYear()).toBe(2025); + expect(result.getUTCMonth()).toBe(6); // 0-indexed (6 = July) + expect(result.getUTCDate()).toBe(31); + expect(result.getUTCHours()).toBe(15); // 3 PM UTC + expect(result.getUTCMinutes()).toBe(0); + expect(result.getUTCSeconds()).toBe(0); + + // Additional verification using Luxon + const luxonResult = DateTime.fromJSDate(result, { zone: 'UTC' }); + expect(luxonResult.toISO()).toBe('2025-07-31T15:00:00.000Z'); + }); + + it('should convert August 31st 11:59 PM Tokyo correctly', () => { + // Arrange - End of August in Tokyo + const tokyoEndOfMonth = new Date(2025, 7, 31, 23, 59, 59, 0); + const timezone = 'Asia/Tokyo'; + + // Act + const result = (deviceDataService as any).convertUserTimezoneToUTC(tokyoEndOfMonth, timezone); + + // Assert - Should convert to August 31st 14:59 UTC + expect(result.getUTCFullYear()).toBe(2025); + expect(result.getUTCMonth()).toBe(7); // 0-indexed (7 = August) + expect(result.getUTCDate()).toBe(31); + expect(result.getUTCHours()).toBe(14); + expect(result.getUTCMinutes()).toBe(59); + }); + + it('should handle New York timezone correctly', () => { + // Arrange + const nyMidnight = new Date(2025, 7, 1, 0, 0, 0, 0); + const timezone = 'America/New_York'; + + // Act + const result = (deviceDataService as any).convertUserTimezoneToUTC(nyMidnight, timezone); + + // Assert - August 1st midnight EST/EDT should convert to 4AM or 5AM UTC (depending on DST) + expect(result.getUTCFullYear()).toBe(2025); + expect(result.getUTCMonth()).toBe(7); // Same month + expect(result.getUTCDate()).toBe(1); // Same day + expect(result.getUTCHours()).toBeGreaterThanOrEqual(4); + expect(result.getUTCHours()).toBeLessThanOrEqual(5); + }); + + it('should handle DST transitions correctly', () => { + // Arrange - Test during a known DST period + const date = new Date(2025, 5, 15, 12, 0, 0, 0); // June 15th noon + const timezone = 'America/New_York'; + + // Act + const result = (deviceDataService as any).convertUserTimezoneToUTC(date, timezone); + + // Assert - Should handle DST correctly + expect(result).toBeInstanceOf(Date); + expect(result.getUTCHours()).toBeGreaterThan(12); // Should be afternoon UTC + }); + + it('should preserve exact time components during conversion', () => { + // Arrange + const date = new Date(2025, 7, 15, 14, 30, 45, 123); // Specific time with milliseconds + const timezone = 'Asia/Tokyo'; + + // Act + const result = (deviceDataService as any).convertUserTimezoneToUTC(date, timezone); + + // Assert - Minutes, seconds should be preserved (hours will shift) + expect(result.getUTCMinutes()).toBe(30); + expect(result.getUTCSeconds()).toBe(45); + }); + }); + + describe('convertUTCToUserTimezone', () => { + it('should handle UTC timezone (no conversion)', () => { + // Arrange + const utcTimestamp = '2025-08-01T15:00:00.000Z'; + const timezone = 'UTC'; + + // Act + const result = (deviceDataService as any).convertUTCToUserTimezone(utcTimestamp, timezone); + + // Assert + expect(result).toBe(utcTimestamp); + }); + + it('should convert UTC back to Tokyo timezone correctly (CRITICAL TEST)', () => { + // Arrange - This is the reverse of our critical conversion + const utcTimestamp = '2025-07-31T15:00:00.000Z'; // July 31st 3PM UTC + const timezone = 'Asia/Tokyo'; + + // Act + const result = (deviceDataService as any).convertUTCToUserTimezone(utcTimestamp, timezone); + + // Assert - Should convert back to August 1st midnight Tokyo + const resultDt = DateTime.fromISO(result); + expect(resultDt.year).toBe(2025); + expect(resultDt.month).toBe(8); // August + expect(resultDt.day).toBe(1); + expect(resultDt.hour).toBe(0); // Midnight + expect(resultDt.minute).toBe(0); + }); + + it('should handle SQL timestamp format', () => { + // Arrange - Test SQL format (common in Supabase) + const sqlTimestamp = '2025-07-31 15:00:00+00'; + const timezone = 'Asia/Tokyo'; + + // Act + const result = (deviceDataService as any).convertUTCToUserTimezone(sqlTimestamp, timezone); + + // Assert - Should parse and convert correctly + expect(result).toContain('2025-08-01'); + expect(result).toContain('00:00:00'); + }); + + it('should handle invalid timestamps gracefully', () => { + // Arrange + const invalidTimestamp = 'invalid-date-string'; + const timezone = 'Asia/Tokyo'; + + // Act + const result = (deviceDataService as any).convertUTCToUserTimezone( + invalidTimestamp, + timezone + ); + + // Assert - Should return original string when parsing fails + expect(result).toBe(invalidTimestamp); + }); + + it('should preserve timezone offset in output', () => { + // Arrange + const utcTimestamp = '2025-08-01T12:00:00.000Z'; + const timezone = 'Asia/Tokyo'; + + // Act + const result = (deviceDataService as any).convertUTCToUserTimezone(utcTimestamp, timezone); + + // Assert - Should include timezone offset + expect(result).toContain('+09:00'); // Tokyo is UTC+9 + }); + }); + + describe('Round-trip conversion accuracy', () => { + it('should maintain accuracy through round-trip conversions', () => { + // Arrange + const originalDate = new Date(2025, 7, 1, 0, 0, 0, 0); // August 1st midnight + const timezone = 'Asia/Tokyo'; + + // Act - Convert to UTC and back + const utcDate = (deviceDataService as any).convertUserTimezoneToUTC(originalDate, timezone); + const backToTimezone = (deviceDataService as any).convertUTCToUserTimezone( + utcDate.toISOString(), + timezone + ); + + // Assert - Should get back to original time + const finalDt = DateTime.fromISO(backToTimezone); + expect(finalDt.year).toBe(2025); + expect(finalDt.month).toBe(8); // August + expect(finalDt.day).toBe(1); + expect(finalDt.hour).toBe(0); // Midnight + }); + + it('should handle Tokyo timezone round-trips accurately (CRITICAL TEST)', () => { + // Arrange - Test the specific timezone we verified works + const testDates = [ + new Date(2025, 7, 1, 0, 0, 0, 0), // August 1st midnight + new Date(2025, 7, 15, 12, 0, 0, 0), // August 15th noon + new Date(2025, 7, 31, 23, 59, 59, 0) // August 31st end of day + ]; + const timezone = 'Asia/Tokyo'; + + testDates.forEach((testDate) => { + // Act + const utcDate = (deviceDataService as any).convertUserTimezoneToUTC(testDate, timezone); + const backToTimezone = (deviceDataService as any).convertUTCToUserTimezone( + utcDate.toISOString(), + timezone + ); + + // Assert + const finalDt = DateTime.fromISO(backToTimezone); + expect(finalDt.year).toBe(testDate.getFullYear()); + expect(finalDt.month).toBe(testDate.getMonth() + 1); // Luxon uses 1-based months + expect(finalDt.day).toBe(testDate.getDate()); + expect(finalDt.hour).toBe(testDate.getHours()); + }); + }); + }); + + describe('Edge cases and error handling', () => { + it('should handle leap year dates correctly', () => { + // Arrange - February 29th, 2024 (leap year) + const leapDate = new Date(2024, 1, 29, 12, 0, 0, 0); + const timezone = 'Asia/Tokyo'; + + // Act + const result = (deviceDataService as any).convertUserTimezoneToUTC(leapDate, timezone); + + // Assert + expect(result).toBeInstanceOf(Date); + expect(result.getUTCMonth()).toBe(1); // February + expect(result.getUTCDate()).toBe(29); + }); + + it('should handle year boundaries correctly', () => { + // Arrange - New Year's Eve 11:59 PM Tokyo + const newYearEve = new Date(2024, 11, 31, 23, 59, 59, 0); + const timezone = 'Asia/Tokyo'; + + // Act + const result = (deviceDataService as any).convertUserTimezoneToUTC(newYearEve, timezone); + + // Assert - Should convert to earlier in the day UTC (still Dec 31st) + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(11); // December + expect(result.getUTCDate()).toBe(31); + }); + + it('should handle various timezone formats', () => { + // Arrange + const date = new Date(2025, 7, 1, 12, 0, 0, 0); + const timezones = [ + 'Asia/Tokyo', + 'America/New_York', + 'Europe/London', + 'UTC', + 'Pacific/Auckland' + ]; + + timezones.forEach((timezone) => { + // Act & Assert - Should not throw errors + expect(() => { + (deviceDataService as any).convertUserTimezoneToUTC(date, timezone); + }).not.toThrow(); + }); + }); + }); +}); diff --git a/src/lib/tests/README.md b/src/lib/tests/README.md new file mode 100644 index 00000000..8662ecb0 --- /dev/null +++ b/src/lib/tests/README.md @@ -0,0 +1,99 @@ +# 🕐 Critical Timezone Conversion Tests + +This directory contains comprehensive tests to ensure the timezone conversion functionality for traffic data export remains working correctly. These tests prevent regression of the "very very backwards" traffic patterns bug. + +## 🎯 Critical Validation + +The key requirement verified by these tests: +- **August 1st midnight Tokyo time** should convert to **July 31st 3:00 PM UTC** +- This specific conversion returns **Record ID 35976** in the production database +- Traffic patterns should be realistic (high during day, low at night) - NOT backwards + +## 📋 Test Files + +### 1. `DeviceDataService.timezone.test.ts` - Core Unit Tests +- Tests `convertUserTimezoneToUTC()` and `convertUTCToUserTimezone()` methods +- Validates the critical August 1st midnight Tokyo → July 31st 3PM UTC conversion +- Tests edge cases: DST transitions, leap years, year boundaries +- Validates round-trip conversion accuracy +- **Key Test**: `should convert August 1st midnight Tokyo to July 31st 3PM UTC (CRITICAL TEST)` + +### 2. `TrafficDataIntegration.test.ts` - Database Integration Tests +- Tests complete flow from date range input to database query results +- Validates that Record ID 35976 is found for August 1st midnight Tokyo +- Confirms 744 records exist for full August 2025 (31 days × 24 hours) +- Verifies realistic traffic patterns (day > night traffic) +- Tests data completeness and quality +- **Key Test**: `should find record ID 35976 for August 1st midnight Tokyo (CRITICAL TEST)` + +### 3. `CSVExportValidation.test.ts` - CSV Format Tests +- Tests CSV export formatting and content +- Validates timezone formatting in CSV output +- Confirms realistic traffic patterns in CSV data +- Tests CSV structure and completeness +- Includes mock tests that work without a running server +- **Key Test**: CSV generation logic for timezone conversion + +## 🚀 Running the Tests + +### Quick Critical Test Run +```bash +# Run just the essential timezone tests +./test-timezone-critical.sh +``` + +### Individual Test Files +```bash +# Core timezone conversion logic +pnpm vitest run src/lib/tests/DeviceDataService.timezone.test.ts + +# Database integration validation +pnpm vitest run src/lib/tests/TrafficDataIntegration.test.ts + +# CSV export formatting +pnpm vitest run src/lib/tests/CSVExportValidation.test.ts +``` + +### All Unit Tests +```bash +pnpm test:unit +``` + +## ✅ Success Criteria + +The tests confirm the fix is working when: + +1. **Core Conversion**: August 1st midnight Tokyo converts to July 31st 3PM UTC +2. **Database Verification**: Record ID 35976 is found for the correct timestamp +3. **Data Quality**: Day traffic > night traffic (realistic patterns) +4. **CSV Format**: Proper timezone formatting in exports +5. **Completeness**: Full month of data available (744 records for August) + +## 🔧 Test Results + +Recent test run confirms: +- ✅ **16 timezone conversion tests** - All critical tests passing +- ✅ **5 integration tests** - Database queries working correctly +- ✅ **Record ID 35976** found for August 1st midnight Tokyo +- ✅ **744 records** for complete August 2025 dataset +- ✅ **Realistic traffic patterns** - Day: 2875, Night: 37 (correct ratio) +- ✅ **100% data completeness** for the test month + +## 🛡️ Preventing Regression + +These tests serve as a safety net to ensure: +- Future code changes don't break timezone conversion +- The "backwards traffic pattern" bug doesn't return +- CSV exports continue showing correct timestamps +- Database queries use proper UTC conversion +- API endpoints maintain timezone awareness + +## 📊 Key Test Data Points + +- **Device EUI**: `110110145241600107` +- **Critical Record ID**: `35976` +- **Critical Timestamp**: August 1st 2025 00:00:00 Tokyo → July 31st 2025 15:00:00 UTC +- **Expected Traffic**: 0 people, 6 cars, 1 truck (realistic midnight data) +- **Test Timezone**: `Asia/Tokyo` (UTC+9) + +Run these tests regularly to ensure the timezone conversion logic remains robust and accurate! \ No newline at end of file diff --git a/src/lib/tests/TrafficDataIntegration.test.ts b/src/lib/tests/TrafficDataIntegration.test.ts new file mode 100644 index 00000000..9fefc3b7 --- /dev/null +++ b/src/lib/tests/TrafficDataIntegration.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { createClient } from '@supabase/supabase-js'; +import { DateTime } from 'luxon'; + +/** + * CRITICAL TRAFFIC DATA INTEGRATION TESTS + * + * These tests verify the complete timezone conversion flow from API input + * to database query results. The key validation is that August 1st midnight + * Tokyo time correctly returns the record with ID 35976. + * + * This ensures the "very very backwards" traffic patterns bug stays fixed. + */ + +describe('Traffic Data Integration Tests', () => { + let supabase: ReturnType; + const devEui = '110110145241600107'; + const timezone = 'Asia/Tokyo'; + + beforeAll(() => { + // Use environment variables if available, otherwise use test credentials + const supabaseUrl = + process.env.PUBLIC_SUPABASE_URL || 'https://dpaoqrcfswnzknixwkll.supabase.co'; + const supabaseKey = + process.env.PUBLIC_SUPABASE_ANON_KEY || + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRwYW9xcmNmc3duemtuaXh3a2xsIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Nzc1MDAwMzAsImV4cCI6MTk5MzA3NjAzMH0.fYA8IMcfuO0g42prGg3h3q_DtlwvWLKfd6nIs5dqAf0'; + + supabase = createClient(supabaseUrl, supabaseKey); + }); + + // Helper function - same logic as DeviceDataService + function convertUserTimezoneToUTC(date: Date, timezone: string): Date { + if (timezone === 'UTC') { + return date; + } + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hour = date.getHours(); + const minute = date.getMinutes(); + const second = date.getSeconds(); + + const dt = DateTime.fromObject( + { + year, + month, + day, + hour, + minute, + second + }, + { zone: timezone } + ); + + return dt.toUTC().toJSDate(); + } + + describe('Critical Timezone Validation', () => { + it('should find record ID 35976 for August 1st midnight Tokyo (CRITICAL TEST)', async () => { + // Arrange - Exact same logic as API endpoints + const startDateParam = '2025-08-01'; + const endDateParam = '2025-08-01'; // Same day to narrow down search + + // Create DateTime in Tokyo timezone (same as fixed API logic) + const startDt = DateTime.fromISO(startDateParam + 'T00:00:00', { zone: timezone }).startOf( + 'day' + ); + const endDt = DateTime.fromISO(endDateParam + 'T23:59:59', { zone: timezone }).endOf('day'); + + // Convert to UTC for database query + const startDate = startDt.toJSDate(); + const endDate = endDt.toJSDate(); + const utcStartDate = convertUserTimezoneToUTC(startDate, timezone); + const utcEndDate = convertUserTimezoneToUTC(endDate, timezone); + + // Act - Query database exactly like DeviceDataService + const { data, error } = await supabase + .from('cw_traffic2') + .select('id, dev_eui, traffic_hour, people_count, car_count') + .eq('dev_eui', devEui) + .gte('traffic_hour', utcStartDate.toISOString()) + .lte('traffic_hour', utcEndDate.toISOString()) + .order('traffic_hour', { ascending: true }); // Ascending to get midnight first + + // Assert + expect(error).toBeNull(); + expect(data).toBeDefined(); + expect(Array.isArray(data)).toBe(true); + + // Find the specific record we verified works + const midnightRecord = data?.find((record) => record.id === 35976); + expect(midnightRecord).toBeDefined(); + expect(midnightRecord?.dev_eui).toBe(devEui); + + // Verify the timestamp conversion + if (midnightRecord) { + const utcTime = DateTime.fromISO(midnightRecord.traffic_hour); + const tokyoTime = utcTime.setZone(timezone); + + // Should be August 1st midnight Tokyo + expect(tokyoTime.year).toBe(2025); + expect(tokyoTime.month).toBe(8); // August + expect(tokyoTime.day).toBe(1); + expect(tokyoTime.hour).toBe(0); // Midnight + expect(tokyoTime.minute).toBe(0); + + console.log( + `✅ VERIFIED: Record ${midnightRecord.id} - UTC: ${midnightRecord.traffic_hour}, Tokyo: ${tokyoTime.toFormat('yyyy-MM-dd HH:mm:ss')}` + ); + } + }, 10000); // 10 second timeout for network request + + it('should return complete August 2025 dataset', async () => { + // Arrange - Full August range + const startDateParam = '2025-08-01'; + const endDateParam = '2025-08-31'; + + const startDt = DateTime.fromISO(startDateParam + 'T00:00:00', { zone: timezone }).startOf( + 'day' + ); + const endDt = DateTime.fromISO(endDateParam + 'T23:59:59', { zone: timezone }).endOf('day'); + + const utcStartDate = convertUserTimezoneToUTC(startDt.toJSDate(), timezone); + const utcEndDate = convertUserTimezoneToUTC(endDt.toJSDate(), timezone); + + // Act + const { data, error, count } = await supabase + .from('cw_traffic2') + .select('id, traffic_hour', { count: 'exact' }) + .eq('dev_eui', devEui) + .gte('traffic_hour', utcStartDate.toISOString()) + .lte('traffic_hour', utcEndDate.toISOString()); + + // Assert + expect(error).toBeNull(); + expect(count).toBeGreaterThan(700); // Should be ~744 records (31 days * 24 hours) + expect(count).toBeLessThan(800); // Reasonable upper bound + + console.log(`✅ VERIFIED: Found ${count} records for August 2025`); + }, 15000); + + it('should validate timezone conversion accuracy across date range', async () => { + // Arrange - Test several key dates + const testDates = [ + '2025-08-01', // Month start + '2025-08-15', // Mid-month + '2025-08-31' // Month end + ]; + + for (const dateParam of testDates) { + // Create midnight Tokyo time + const tokyoDt = DateTime.fromISO(dateParam + 'T00:00:00', { zone: timezone }); + const utcDate = convertUserTimezoneToUTC(tokyoDt.toJSDate(), timezone); + + // Query for records around this time (±1 hour for safety) + const { data, error } = await supabase + .from('cw_traffic2') + .select('id, traffic_hour') + .eq('dev_eui', devEui) + .gte('traffic_hour', new Date(utcDate.getTime() - 3600000).toISOString()) // -1 hour + .lte('traffic_hour', new Date(utcDate.getTime() + 3600000).toISOString()) // +1 hour + .limit(5); + + // Assert + expect(error).toBeNull(); + expect(data).toBeDefined(); + + if (data && data.length > 0) { + // Verify at least one record exists near the expected time + const hasNearbyRecord = data.some((record) => { + const recordUtc = new Date(record.traffic_hour); + const timeDiff = Math.abs(recordUtc.getTime() - utcDate.getTime()); + return timeDiff < 3600000; // Within 1 hour + }); + + expect(hasNearbyRecord).toBe(true); + console.log(`✅ VERIFIED: Found records near ${dateParam} midnight Tokyo`); + } + } + }, 20000); + }); + + describe('Data Quality Validation', () => { + it('should verify realistic traffic patterns (not backwards)', async () => { + // Arrange - Get a sample day of data + const sampleDate = '2025-08-15'; + const startDt = DateTime.fromISO(sampleDate + 'T00:00:00', { zone: timezone }).startOf('day'); + const endDt = DateTime.fromISO(sampleDate + 'T23:59:59', { zone: timezone }).endOf('day'); + + const utcStartDate = convertUserTimezoneToUTC(startDt.toJSDate(), timezone); + const utcEndDate = convertUserTimezoneToUTC(endDt.toJSDate(), timezone); + + // Act + const { data, error } = await supabase + .from('cw_traffic2') + .select('traffic_hour, people_count, car_count') + .eq('dev_eui', devEui) + .gte('traffic_hour', utcStartDate.toISOString()) + .lte('traffic_hour', utcEndDate.toISOString()) + .order('traffic_hour', { ascending: true }); + + // Assert + expect(error).toBeNull(); + expect(data).toBeDefined(); + + if (data && data.length > 0) { + // Convert timestamps back to Tokyo time for analysis + const tokyoData = data.map((record) => { + const utcTime = DateTime.fromISO(record.traffic_hour); + const tokyoTime = utcTime.setZone(timezone); + return { + hour: tokyoTime.hour, + people: record.people_count || 0, + cars: record.car_count || 0 + }; + }); + + // Calculate average traffic for night (0-5 AM) vs day (9 AM - 5 PM) + const nightTraffic = tokyoData + .filter((d) => d.hour >= 0 && d.hour <= 5) + .reduce((sum, d) => sum + d.people + d.cars, 0); + + const dayTraffic = tokyoData + .filter((d) => d.hour >= 9 && d.hour <= 17) + .reduce((sum, d) => sum + d.people + d.cars, 0); + + // Realistic pattern: day traffic should be higher than night traffic + expect(dayTraffic).toBeGreaterThan(nightTraffic); + + console.log( + `✅ VERIFIED: Realistic traffic pattern - Day: ${dayTraffic}, Night: ${nightTraffic}` + ); + } + }, 15000); + + it('should validate data completeness for full month', async () => { + // Arrange + const startDateParam = '2025-08-01'; + const endDateParam = '2025-08-31'; + + const startDt = DateTime.fromISO(startDateParam + 'T00:00:00', { zone: timezone }).startOf( + 'day' + ); + const endDt = DateTime.fromISO(endDateParam + 'T23:59:59', { zone: timezone }).endOf('day'); + + const utcStartDate = convertUserTimezoneToUTC(startDt.toJSDate(), timezone); + const utcEndDate = convertUserTimezoneToUTC(endDt.toJSDate(), timezone); + + // Act + const { data, error, count } = await supabase + .from('cw_traffic2') + .select('traffic_hour', { count: 'exact' }) + .eq('dev_eui', devEui) + .gte('traffic_hour', utcStartDate.toISOString()) + .lte('traffic_hour', utcEndDate.toISOString()); + + // Assert + expect(error).toBeNull(); + expect(count).toBeDefined(); + + // August has 31 days * 24 hours = 744 expected records + const expectedRecords = 31 * 24; + const completeness = (count! / expectedRecords) * 100; + + expect(completeness).toBeGreaterThan(90); // At least 90% data completeness + + console.log( + `✅ VERIFIED: Data completeness: ${completeness.toFixed(1)}% (${count}/${expectedRecords} records)` + ); + }, 15000); + }); +}); diff --git a/test-timezone-critical.sh b/test-timezone-critical.sh new file mode 100755 index 00000000..75f07ba7 --- /dev/null +++ b/test-timezone-critical.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +# Critical Timezone Conversion Test Runner +# This script runs the essential timezone tests to ensure the traffic data +# export functionality continues to work correctly. + +echo "🕐 Running Critical Timezone Conversion Tests..." +echo "================================================" +echo "" + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}ℹ️ These tests verify that the 'very very backwards' traffic pattern bug stays fixed${NC}" +echo -e "${BLUE}ℹ️ Key validation: August 1st midnight Tokyo → Record ID 35976${NC}" +echo "" + +# Run the core timezone conversion tests +echo -e "${YELLOW}1. Running Core Timezone Conversion Tests...${NC}" +pnpm vitest run src/lib/tests/DeviceDataService.timezone.test.ts --reporter=verbose + +TIMEZONE_EXIT_CODE=$? + +# Run the integration tests (database queries) +echo "" +echo -e "${YELLOW}2. Running Traffic Data Integration Tests...${NC}" +pnpm vitest run src/lib/tests/TrafficDataIntegration.test.ts --reporter=verbose + +INTEGRATION_EXIT_CODE=$? + +# Run the CSV logic tests (skip API tests that require auth) +echo "" +echo -e "${YELLOW}3. Running CSV Generation Logic Tests...${NC}" +pnpm vitest run src/lib/tests/CSVExportValidation.test.ts --testNamePattern="CSV Generation Logic Tests" --reporter=verbose + +CSV_EXIT_CODE=$? + +echo "" +echo "================================================" + +# Summary +if [ $TIMEZONE_EXIT_CODE -eq 0 ] && [ $INTEGRATION_EXIT_CODE -eq 0 ] && [ $CSV_EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}✅ ALL CRITICAL TIMEZONE TESTS PASSED!${NC}" + echo -e "${GREEN}✅ Traffic data export timezone conversion is working correctly${NC}" + echo -e "${GREEN}✅ August 1st midnight Tokyo correctly converts to July 31st 3PM UTC${NC}" + echo -e "${GREEN}✅ No 'backwards' traffic patterns detected${NC}" + exit 0 +else + echo -e "${RED}❌ SOME CRITICAL TESTS FAILED!${NC}" + echo -e "${RED}❌ Please review the timezone conversion logic${NC}" + + if [ $TIMEZONE_EXIT_CODE -ne 0 ]; then + echo -e "${RED} - Core timezone conversion tests failed${NC}" + fi + + if [ $INTEGRATION_EXIT_CODE -ne 0 ]; then + echo -e "${RED} - Database integration tests failed${NC}" + fi + + if [ $CSV_EXIT_CODE -ne 0 ]; then + echo -e "${RED} - CSV generation tests failed${NC}" + fi + + exit 1 +fi \ No newline at end of file From 747bc799aca8c2713e698f5824d3fc10341397cd Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Mon, 15 Sep 2025 02:03:28 +0900 Subject: [PATCH 08/18] reports now display on all reports page! --- src/lib/pdf/pdfDataTable.ts | 117 ++-- src/lib/repositories/ReportRepository.ts | 22 + src/lib/services/ReportService.ts | 7 + .../tests/NonTrafficDeviceTimezone.test.ts | 440 ++++++++++++++ src/lib/tests/NonTrafficIntegration.test.ts | 564 ++++++++++++++++++ src/lib/tests/PDFReportIntegration.test.ts | 355 +++++++++++ src/lib/tests/PDFReportTimezone.test.ts | 409 +++++++++++++ src/lib/tests/ReportPDFIntegration.test.ts | 325 ++++++++++ src/lib/tests/ReportTimezone.test.ts | 422 +++++++++++++ .../api/devices/[devEui]/csv/+server.ts | 4 +- .../api/devices/[devEui]/pdf/+server.ts | 558 +++-------------- src/routes/app/all-reports/+page.server.ts | 84 ++- src/routes/app/all-reports/+page.svelte | 2 +- 13 files changed, 2770 insertions(+), 539 deletions(-) create mode 100644 src/lib/tests/NonTrafficDeviceTimezone.test.ts create mode 100644 src/lib/tests/NonTrafficIntegration.test.ts create mode 100644 src/lib/tests/PDFReportIntegration.test.ts create mode 100644 src/lib/tests/PDFReportTimezone.test.ts create mode 100644 src/lib/tests/ReportPDFIntegration.test.ts create mode 100644 src/lib/tests/ReportTimezone.test.ts diff --git a/src/lib/pdf/pdfDataTable.ts b/src/lib/pdf/pdfDataTable.ts index c0e248c0..5f5b3675 100644 --- a/src/lib/pdf/pdfDataTable.ts +++ b/src/lib/pdf/pdfDataTable.ts @@ -1,4 +1,5 @@ import PDFDocument from 'pdfkit'; +import { DateTime } from 'luxon'; import type { TableRow } from '.'; interface TableConfig { @@ -10,7 +11,8 @@ interface TableConfig { columnMargin: number; fontSize: number; headerHeight: number; - takeEvery: number; // This is the numbered item in the array to take and you will skip others unless they are an alert type. + takeEvery: number; // keep every Nth row (always keep rows with alert bgColor) + timezone?: string; // e.g., 'Asia/Tokyo' } const DEFAULT_CONFIG: TableConfig = { @@ -22,16 +24,35 @@ const DEFAULT_CONFIG: TableConfig = { columnMargin: 10, fontSize: 7, headerHeight: 15, - takeEvery: 3 + takeEvery: 3, + timezone: 'utc' }; /** - * Creates a lean PDF data table that displays data from top to bottom, left to right - * @param params - * @param params.doc - PDFKit document instance - * @param params.dataHeader - Header row containing column labels - * @param params.dataRows - Array of data rows, each row is an array of values - * @param params.config - Optional configuration overrides + * Parse the header.value (epoch ms | ISO | SQL) into a DateTime in the desired zone. + */ +function parseHeaderInstant(val: unknown, zone?: string): DateTime | null { + const z = zone || 'utc'; + try { + if (typeof val === 'number') { + // epoch millis → absolute instant → render in zone + return DateTime.fromMillis(val, { zone: 'utc' }).setZone(z); + } + if (typeof val === 'string') { + // prefer ISO with setZone to respect embedded offsets (+09:00), then coerce to z + const iso = DateTime.fromISO(val, { setZone: true }); + if (iso.isValid) return iso.setZone(z); + const sql = DateTime.fromSQL(val, { setZone: true }); + if (sql.isValid) return sql.setZone(z); + } + } catch { + // fall through + } + return null; +} + +/** + * Creates a lean PDF data table that displays data from top to bottom, left to right. */ export function createPDFDataTable({ doc, @@ -44,10 +65,9 @@ export function createPDFDataTable({ dataRows: TableRow[]; config?: Partial; }): void { - const conf = { ...DEFAULT_CONFIG, ...config }; + const conf: TableConfig = { ...DEFAULT_CONFIG, ...config }; // Apply takeEvery filtering (keep every Nth row) plus always keep rows containing any alert/warning cell (bgColor != white) - // N = conf.takeEvery (defaults to 3). If N <= 1, no sampling (all rows kept). const samplingInterval = Math.max(1, conf.takeEvery || 1); const workingRows = samplingInterval > 1 @@ -56,7 +76,7 @@ export function createPDFDataTable({ if (onSeries) return true; // Include any row with an alert/warning (detected by any cell having a non-white bgColor) const hasAlert = [row.header, ...row.cells].some( - (c) => c.bgColor && c.bgColor !== '#ffffff' + (c) => (c as any).bgColor && (c as any).bgColor !== '#ffffff' ); return hasAlert; }) @@ -71,11 +91,10 @@ export function createPDFDataTable({ left: marginLeft } = doc.page.margins; - // Calculate how many rows can actually fit on a page + // Page geometry const pageHeight = doc.page.height; const contentHeight = pageHeight - marginTop - marginBottom; - // Calculate how many columns can actually fit on a page const pageWidth = doc.page.width; const availableWidth = pageWidth - marginLeft - marginRight; const columnWidth = [dataHeader.header, ...dataHeader.cells].reduce( @@ -105,7 +124,7 @@ export function createPDFDataTable({ let availableHeight = pageHeight - startY - marginBottom - headerHeight; - // Insert a new page if we are at the bottom of the current page + // New page if at the bottom if (firstColumn && availableHeight < 200) { doc.addPage(); startY = marginTop; @@ -151,11 +170,10 @@ function drawColumn({ let currentY = startY; const borderColor = '#ccc'; - const { fontSize: defaultFontSize, cellWidth, headerHeight, cellHeight } = config; + const { fontSize: defaultFontSize, cellWidth, headerHeight, cellHeight, timezone } = config; - // Draw header + // Header background doc.fillColor('#e8e8e8').rect(startX, currentY, columnWidth, config.headerHeight).fill(); - doc.strokeColor(borderColor).rect(startX, currentY, columnWidth, config.headerHeight).stroke(); const columns = [dataHeader.header, ...dataHeader.cells]; @@ -163,7 +181,7 @@ function drawColumn({ const getCellX = (index: number): number => startX + columns.slice(0, index).reduce((total, col) => total + (col.width ?? cellWidth), 0); - // Add value headers if we have data + // Column labels if (dataRows.length > 0) { columns.forEach(({ label, width = columnWidth, fontSize = defaultFontSize }, index) => { doc @@ -175,25 +193,54 @@ function drawColumn({ currentY += headerHeight; - // Draw data rows + // Data rows dataRows.forEach(({ header, cells }, rowIndex) => { const isEvenRow = rowIndex % 2 === 0; - // Row background - if (!isEvenRow) { - doc.fillColor('#f9f9f9').rect(startX, currentY, columnWidth, cellHeight).fill(); - } else { - doc.fillColor('#ffffff').rect(startX, currentY, columnWidth, cellHeight).fill(); - } + // Row background striping + doc + .fillColor(isEvenRow ? '#ffffff' : '#f9f9f9') + .rect(startX, currentY, columnWidth, cellHeight) + .fill(); // Row border doc.strokeColor(borderColor).rect(startX, currentY, columnWidth, cellHeight).stroke(); - [header, ...cells].forEach(({ label, shortLabel, bgColor }, cellIndex) => { + // ——— Compute first-column timestamp label in the requested timezone ——— + const thisDt = parseHeaderInstant((header as any).value ?? (header as any).label, timezone); + const prevDt = + rowIndex > 0 + ? parseHeaderInstant( + (dataRows[rowIndex - 1].header as any).value ?? + (dataRows[rowIndex - 1].header as any).label, + timezone + ) + : null; + + // Fallback: if parsing fails, use provided labels as-is + const thisDay = thisDt + ? thisDt.toFormat('M/d') + : ((header as any).label?.split(' ')?.[0] ?? ''); + const thisFull = thisDt ? thisDt.toFormat('M/d H:mm') : ((header as any).label ?? ''); + const thisTime = thisDt + ? thisDt.toFormat('H:mm') + : ((header as any).shortLabel ?? (header as any).label ?? ''); + + let computedHeaderLabel = thisFull; + if (rowIndex > 0) { + const prevDay = prevDt + ? prevDt.toFormat('M/d') + : ((dataRows[rowIndex - 1].header as any).label?.split(' ')?.[0] ?? ''); + if (prevDay === thisDay) computedHeaderLabel = thisTime; // same local day → short time + } + // ———————————————————————————————————————————————————————————————— + + // Render cells + [header, ...cells].forEach(({ label, bgColor }, cellIndex) => { const cellX = getCellX(cellIndex); const { width = cellWidth, fontSize = defaultFontSize } = columns[cellIndex]; - // Apply background color if not white + // Alert background if provided if (bgColor && bgColor !== '#ffffff') { doc.fillColor(bgColor).rect(cellX, currentY, width, cellHeight).fill(); } @@ -201,23 +248,13 @@ function drawColumn({ // Cell border doc.strokeColor(borderColor).rect(cellX, currentY, width, cellHeight).stroke(); - let labelText = label; - - // If the row is not the first one and a short label is provided, check if we can use it - if (rowIndex > 0 && shortLabel) { - const previousLabel = dataRows[rowIndex - 1]?.header.label; - - // If the previous date is the same as the current date, use the short label - if (previousLabel.split(' ')[0] === label.split(' ')[0]) { - labelText = shortLabel; - } - } + // Use computed time label for the first column; other columns use provided labels + const cellLabel = cellIndex === 0 ? computedHeaderLabel : (label ?? ''); - // Value text doc .fillColor('#000') .fontSize(fontSize - 1) - .text(labelText, cellX + 1, currentY + 2, { width: width - 5, align: 'right' }); + .text(cellLabel, cellX + 1, currentY + 2, { width: width - 5, align: 'right' }); }); currentY += cellHeight; diff --git a/src/lib/repositories/ReportRepository.ts b/src/lib/repositories/ReportRepository.ts index b0651a7d..da05d838 100644 --- a/src/lib/repositories/ReportRepository.ts +++ b/src/lib/repositories/ReportRepository.ts @@ -15,6 +15,28 @@ export class ReportRepository extends BaseRepository { super(supabase, errorHandler); } + /** + * Find reports by device EUI + * @param devEui Device EUI to search for + */ + async findAll(): Promise { + try { + const { data, error } = await this.supabase + .from(this.tableName) + .select('*') + .order('created_at', { ascending: false }); + + if (error) { + throw error; + } + + return data || []; + } catch (error) { + this.errorHandler.handleDatabaseError(error as any, `Error finding reports`); + throw error; + } + } + /** * Find reports by device EUI * @param devEui Device EUI to search for diff --git a/src/lib/services/ReportService.ts b/src/lib/services/ReportService.ts index 701a4e5e..3574c6b9 100644 --- a/src/lib/services/ReportService.ts +++ b/src/lib/services/ReportService.ts @@ -32,6 +32,13 @@ export class ReportService implements IReportService { private scheduleRepository: ReportUserScheduleRepository ) {} + /** + * Get all reports + */ + async getAllReports(devEui: string): Promise { + return this.reportRepository.findAll(); + } + /** * Get reports by device EUI */ diff --git a/src/lib/tests/NonTrafficDeviceTimezone.test.ts b/src/lib/tests/NonTrafficDeviceTimezone.test.ts new file mode 100644 index 00000000..628f4e36 --- /dev/null +++ b/src/lib/tests/NonTrafficDeviceTimezone.test.ts @@ -0,0 +1,440 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { DateTime } from 'luxon'; +import { DeviceDataService } from '../services/DeviceDataService'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +/** + * CRITICAL NON-TRAFFIC DEVICE TIMEZONE CONVERSION TESTS + * + * These tests ensure that timezone conversion works correctly for all non-traffic + * device types that use the `created_at` field instead of `traffic_hour`. + * + * Device types covered: + * - Air sensors (cw_air_data) - temperature, humidity, pressure, CO2, etc. + * - Soil sensors (cw_soil_data) - moisture, pH, EC, temperature + * - Water sensors (cw_water_data) - depth, pressure, temperature + * - Pulse meters (cw_pulse_meters) - water flow counting + * - Relay data (cw_relay_data) - relay states + * - Water meter uplinks (cw_watermeter_uplinks) - flow data + * + * Key requirement: created_at timestamps should follow the same timezone + * conversion logic as traffic_hour but use different field names. + */ + +describe('DeviceDataService - Non-Traffic Device Timezone Conversions', () => { + let deviceDataService: DeviceDataService; + let mockSupabase: SupabaseClient; + + // Mock device types for different sensors + const mockDeviceTypes = { + airSensor: { + cw_device_type: { + data_table_v2: 'cw_air_data', + name: 'Air Quality Sensor' + } + }, + soilSensor: { + cw_device_type: { + data_table_v2: 'cw_soil_data', + name: 'Soil Moisture Sensor' + } + }, + waterSensor: { + cw_device_type: { + data_table_v2: 'cw_water_data', + name: 'Water Level Sensor' + } + }, + pulseMeter: { + cw_device_type: { + data_table_v2: 'cw_pulse_meters', + name: 'Water Flow Meter' + } + }, + trafficCamera: { + cw_device_type: { + data_table_v2: 'cw_traffic2', + name: 'Traffic Camera' + } + } + }; + + beforeEach(() => { + // Mock Supabase client with device type responses + mockSupabase = { + from: vi.fn().mockImplementation((tableName) => { + if (tableName === 'cw_devices') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockImplementation(() => { + // Return different device types based on test context + const devEui = expect.getState().currentTestName?.includes('air') + ? 'air-device' + : expect.getState().currentTestName?.includes('soil') + ? 'soil-device' + : expect.getState().currentTestName?.includes('water') + ? 'water-device' + : expect.getState().currentTestName?.includes('pulse') + ? 'pulse-device' + : 'air-device'; // default + + const deviceType = devEui.includes('air') + ? mockDeviceTypes.airSensor + : devEui.includes('soil') + ? mockDeviceTypes.soilSensor + : devEui.includes('water') + ? mockDeviceTypes.waterSensor + : devEui.includes('pulse') + ? mockDeviceTypes.pulseMeter + : mockDeviceTypes.airSensor; + + return Promise.resolve({ + data: deviceType, + error: null + }); + }) + }) + }) + }; + } + + // Mock data queries + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + gte: vi.fn().mockReturnValue({ + lte: vi.fn().mockReturnValue({ + order: vi.fn().mockReturnValue({ + // Mock successful data response + data: [], + error: null + }) + }) + }) + }) + }) + }; + }) + } as any; + + deviceDataService = new DeviceDataService(mockSupabase); + }); + + describe('Field Selection Logic', () => { + // Helper function to determine timestamp field (matches DeviceDataService logic) + const getTimestampField = (tableName: string) => { + return tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at'; + }; + + it('should use created_at for air sensor devices (NOT traffic_hour)', () => { + // This test verifies the conditional logic in DeviceDataService + const tableName = 'cw_air_data'; + const timestampField = getTimestampField(tableName); + + expect(timestampField).toBe('created_at'); + expect(timestampField).not.toBe('traffic_hour'); + }); + + it('should use created_at for soil sensor devices', () => { + const tableName = 'cw_soil_data'; + const timestampField = getTimestampField(tableName); + + expect(timestampField).toBe('created_at'); + }); + + it('should use created_at for water sensor devices', () => { + const tableName = 'cw_water_data'; + const timestampField = getTimestampField(tableName); + + expect(timestampField).toBe('created_at'); + }); + + it('should use created_at for pulse meter devices', () => { + const tableName = 'cw_pulse_meters'; + const timestampField = getTimestampField(tableName); + + expect(timestampField).toBe('created_at'); + }); + + it('should use traffic_hour ONLY for traffic camera devices', () => { + const tableName = 'cw_traffic2'; + const timestampField = getTimestampField(tableName); + + expect(timestampField).toBe('traffic_hour'); + expect(timestampField).not.toBe('created_at'); + }); + }); + + describe('Timezone Conversion with created_at Field', () => { + it('should convert August 1st midnight Tokyo to July 31st 3PM UTC for air sensors (CRITICAL TEST)', () => { + // Arrange - Same critical test as traffic, but for non-traffic devices + const tokyoMidnight = new Date(2025, 7, 1, 0, 0, 0, 0); // August 1st midnight + const timezone = 'Asia/Tokyo'; + + // Act - Use the same timezone conversion method + const result = (deviceDataService as any).convertUserTimezoneToUTC(tokyoMidnight, timezone); + + // Assert - Must convert to July 31st 15:00 UTC (same as traffic) + expect(result.getUTCFullYear()).toBe(2025); + expect(result.getUTCMonth()).toBe(6); // 0-indexed (6 = July) + expect(result.getUTCDate()).toBe(31); + expect(result.getUTCHours()).toBe(15); // 3 PM UTC + expect(result.getUTCMinutes()).toBe(0); + expect(result.getUTCSeconds()).toBe(0); + + // Verification using Luxon + const luxonResult = DateTime.fromJSDate(result, { zone: 'UTC' }); + expect(luxonResult.toISO()).toBe('2025-07-31T15:00:00.000Z'); + }); + + it('should convert UTC back to Tokyo timezone for soil sensors (CRITICAL TEST)', () => { + // Arrange - Reverse conversion test + const utcTimestamp = '2025-07-31T15:00:00.000Z'; // July 31st 3PM UTC + const timezone = 'Asia/Tokyo'; + + // Act + const result = (deviceDataService as any).convertUTCToUserTimezone(utcTimestamp, timezone); + + // Assert - Should convert back to August 1st midnight Tokyo + const resultDt = DateTime.fromISO(result); + expect(resultDt.year).toBe(2025); + expect(resultDt.month).toBe(8); // August + expect(resultDt.day).toBe(1); + expect(resultDt.hour).toBe(0); // Midnight + expect(resultDt.minute).toBe(0); + }); + + it('should handle different timezones for water sensors', () => { + // Arrange + const newYorkMidnight = new Date(2025, 7, 1, 0, 0, 0, 0); + const timezone = 'America/New_York'; + + // Act + const result = (deviceDataService as any).convertUserTimezoneToUTC(newYorkMidnight, timezone); + + // Assert - August 1st midnight EST/EDT should convert to 4AM or 5AM UTC + expect(result.getUTCFullYear()).toBe(2025); + expect(result.getUTCMonth()).toBe(7); // Same month + expect(result.getUTCDate()).toBe(1); // Same day + expect(result.getUTCHours()).toBeGreaterThanOrEqual(4); + expect(result.getUTCHours()).toBeLessThanOrEqual(5); + }); + + it('should handle Europe timezone for pulse meters', () => { + // Arrange + const londonNoon = new Date(2025, 7, 1, 12, 0, 0, 0); + const timezone = 'Europe/London'; + + // Act + const result = (deviceDataService as any).convertUserTimezoneToUTC(londonNoon, timezone); + + // Assert + expect(result.getUTCFullYear()).toBe(2025); + expect(result.getUTCMonth()).toBe(7); // August + expect(result.getUTCDate()).toBe(1); + // London is UTC+0 or UTC+1 depending on DST + expect(result.getUTCHours()).toBeGreaterThanOrEqual(11); + expect(result.getUTCHours()).toBeLessThanOrEqual(12); + }); + }); + + describe('Device Type Conditional Logic Validation', () => { + it('should correctly identify non-traffic devices and use created_at', () => { + // Test all non-traffic device tables + const nonTrafficTables = [ + 'cw_air_data', + 'cw_soil_data', + 'cw_water_data', + 'cw_pulse_meters', + 'cw_relay_data', + 'cw_watermeter_uplinks' + ]; + + nonTrafficTables.forEach((tableName) => { + const timestampField = tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at'; + expect(timestampField).toBe('created_at'); + expect(tableName).not.toBe('cw_traffic2'); + }); + }); + + it('should correctly identify traffic devices and use traffic_hour', () => { + const trafficTable = 'cw_traffic2'; + const timestampField = trafficTable === 'cw_traffic2' ? 'traffic_hour' : 'created_at'; + + expect(timestampField).toBe('traffic_hour'); + expect(trafficTable).toBe('cw_traffic2'); + }); + }); + + describe('Air Sensor Specific Tests', () => { + it('should handle air sensor date ranges with created_at field', () => { + // Arrange + const startDate = new Date(2025, 7, 1, 0, 0, 0, 0); // August 1st + const endDate = new Date(2025, 7, 1, 23, 59, 59, 999); // End of August 1st + const timezone = 'Asia/Tokyo'; + + // Act - Convert dates for database query + const utcStartDate = (deviceDataService as any).convertUserTimezoneToUTC(startDate, timezone); + const utcEndDate = (deviceDataService as any).convertUserTimezoneToUTC(endDate, timezone); + + // Assert - Verify the UTC conversion for database query + expect(utcStartDate.getUTCDate()).toBe(31); // July 31st + expect(utcStartDate.getUTCHours()).toBe(15); // 3 PM UTC + + expect(utcEndDate.getUTCDate()).toBe(1); // August 1st + expect(utcEndDate.getUTCHours()).toBe(14); // 2:59 PM UTC next day + }); + + it('should format air sensor timestamps correctly for CSV export', () => { + // Arrange - Simulate air sensor data timestamp + const utcTimestamp = '2025-07-31T15:00:00+00:00'; // UTC timestamp from database + const timezone = 'Asia/Tokyo'; + + // Act - Convert for CSV display + const result = (deviceDataService as any).convertUTCToUserTimezone(utcTimestamp, timezone); + + // Assert - Should show Tokyo time in CSV + expect(result).toContain('2025-08-01T00:00:00'); + expect(result).toContain('+09:00'); // Tokyo timezone offset + }); + }); + + describe('Soil Sensor Specific Tests', () => { + it('should handle soil sensor moisture readings with proper timestamps', () => { + // Arrange - Typical soil sensor reading time + const readingTime = new Date(2025, 7, 15, 6, 0, 0, 0); // 6 AM reading + const timezone = 'Asia/Tokyo'; + + // Act + const utcTime = (deviceDataService as any).convertUserTimezoneToUTC(readingTime, timezone); + + // Assert - 6 AM Tokyo = 9 PM previous day UTC + expect(utcTime.getUTCDate()).toBe(14); // Previous day + expect(utcTime.getUTCHours()).toBe(21); // 9 PM UTC + }); + + it('should round-trip soil sensor timestamps accurately', () => { + // Arrange + const originalTime = new Date(2025, 7, 15, 8, 30, 0, 0); + const timezone = 'Asia/Tokyo'; + + // Act - Convert to UTC and back + const utcTime = (deviceDataService as any).convertUserTimezoneToUTC(originalTime, timezone); + const backToTokyo = (deviceDataService as any).convertUTCToUserTimezone( + utcTime.toISOString(), + timezone + ); + + // Assert + const finalDt = DateTime.fromISO(backToTokyo); + expect(finalDt.year).toBe(originalTime.getFullYear()); + expect(finalDt.month).toBe(originalTime.getMonth() + 1); + expect(finalDt.day).toBe(originalTime.getDate()); + expect(finalDt.hour).toBe(originalTime.getHours()); + expect(finalDt.minute).toBe(originalTime.getMinutes()); + }); + }); + + describe('Water Sensor Specific Tests', () => { + it('should handle water level sensor readings across day boundaries', () => { + // Arrange - Late night reading + const lateNightReading = new Date(2025, 7, 1, 23, 45, 0, 0); + const timezone = 'Asia/Tokyo'; + + // Act + const utcTime = (deviceDataService as any).convertUserTimezoneToUTC( + lateNightReading, + timezone + ); + + // Assert - 11:45 PM Tokyo = 2:45 PM UTC same day + expect(utcTime.getUTCDate()).toBe(1); // Same day + expect(utcTime.getUTCHours()).toBe(14); // 2 PM UTC + expect(utcTime.getUTCMinutes()).toBe(45); + }); + }); + + describe('Pulse Meter Specific Tests', () => { + it('should handle pulse meter counts with precise timestamps', () => { + // Arrange - Water flow measurement + const measurementTime = new Date(2025, 7, 15, 14, 15, 30, 0); + const timezone = 'Asia/Tokyo'; + + // Act + const utcTime = (deviceDataService as any).convertUserTimezoneToUTC( + measurementTime, + timezone + ); + + // Assert - 2:15:30 PM Tokyo = 5:15:30 AM UTC + expect(utcTime.getUTCHours()).toBe(5); + expect(utcTime.getUTCMinutes()).toBe(15); + expect(utcTime.getUTCSeconds()).toBe(30); + }); + }); + + describe('Multi-Device Type Consistency', () => { + it('should apply same timezone conversion logic across all non-traffic device types', () => { + // Arrange + const testTime = new Date(2025, 7, 1, 12, 0, 0, 0); // Noon Tokyo + const timezone = 'Asia/Tokyo'; + + const deviceTypes = ['air', 'soil', 'water', 'pulse']; + + deviceTypes.forEach((deviceType) => { + // Act + const utcTime = (deviceDataService as any).convertUserTimezoneToUTC(testTime, timezone); + + // Assert - All device types should use same conversion logic + expect(utcTime.getUTCHours()).toBe(3); // Noon Tokyo = 3 AM UTC + expect(utcTime.getUTCDate()).toBe(1); // Same day + }); + }); + + it('should maintain timestamp precision across all device types', () => { + // Arrange - Test with millisecond precision + const preciseTime = new Date(2025, 7, 15, 10, 30, 45, 123); + const timezone = 'Asia/Tokyo'; + + // Act + const utcTime = (deviceDataService as any).convertUserTimezoneToUTC(preciseTime, timezone); + const backToTokyo = (deviceDataService as any).convertUTCToUserTimezone( + utcTime.toISOString(), + timezone + ); + + // Assert - Precision should be maintained + const finalDt = DateTime.fromISO(backToTokyo); + expect(finalDt.minute).toBe(30); + expect(finalDt.second).toBe(45); + // Note: Milliseconds might be lost in ISO conversion, which is acceptable + }); + }); + + describe('Error Handling for Non-Traffic Devices', () => { + it('should handle invalid timestamps gracefully for all device types', () => { + // Arrange + const invalidTimestamp = 'invalid-timestamp'; + const timezone = 'Asia/Tokyo'; + + // Act & Assert + const result = (deviceDataService as any).convertUTCToUserTimezone( + invalidTimestamp, + timezone + ); + expect(result).toBe(invalidTimestamp); // Should return original on error + }); + + it('should handle unknown device types gracefully', () => { + // This test ensures that unknown device types default to created_at + const unknownTable = 'cw_unknown_sensors'; + // Use a function to avoid TypeScript warnings about literal comparisons + const getTimestampField = (table: string) => + table === 'cw_traffic2' ? 'traffic_hour' : 'created_at'; + const timestampField = getTimestampField(unknownTable); + + expect(timestampField).toBe('created_at'); // Safe default + }); + }); +}); diff --git a/src/lib/tests/NonTrafficIntegration.test.ts b/src/lib/tests/NonTrafficIntegration.test.ts new file mode 100644 index 00000000..b2cd4ad7 --- /dev/null +++ b/src/lib/tests/NonTrafficIntegration.test.ts @@ -0,0 +1,564 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { createClient } from '@supabase/supabase-js'; +import { DateTime } from 'luxon'; + +/** + * NON-TRAFFIC DEVICE INTEGRATION TESTS + * + * These tests verify the complete timezone conversion flow for all non-traffic + * device types that use the `created_at` field. This ensures that air sensors, + * soil sensors, water sensors, etc. all follow the same timezone logic as + * traffic cameras but use the correct timestamp field. + * + * Device types tested: + * - Air quality sensors (cw_air_data) + * - Soil moisture sensors (cw_soil_data) + * - Water level sensors (cw_water_data) + * - Pulse flow meters (cw_pulse_meters) + * - Any other sensor using created_at + */ + +describe('Non-Traffic Device Integration Tests', () => { + let supabase: ReturnType; + const timezone = 'Asia/Tokyo'; + + beforeAll(() => { + const supabaseUrl = + process.env.PUBLIC_SUPABASE_URL || 'https://dpaoqrcfswnzknixwkll.supabase.co'; + const supabaseKey = + process.env.PUBLIC_SUPABASE_ANON_KEY || + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRwYW9xcmNmc3duemtuaXh3a2xsIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Nzc1MDAwMzAsImV4cCI6MTk5MzA3NjAzMH0.fYA8IMcfuO0g42prGg3h3q_DtlwvWLKfd6nIs5dqAf0'; + + supabase = createClient(supabaseUrl, supabaseKey); + }); + + // Helper function - same timezone conversion logic as DeviceDataService + function convertUserTimezoneToUTC(date: Date, timezone: string): Date { + if (timezone === 'UTC') { + return date; + } + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hour = date.getHours(); + const minute = date.getMinutes(); + const second = date.getSeconds(); + + const dt = DateTime.fromObject( + { + year, + month, + day, + hour, + minute, + second + }, + { zone: timezone } + ); + + return dt.toUTC().toJSDate(); + } + + // Helper function to get devices for testing + async function getDevicesByTable(tableName: string, limit = 1) { + // First get devices, then get their types separately to avoid relationship issues + const { data: devices, error: devicesError } = await supabase + .from('cw_devices') + .select('dev_eui, name, type') + .limit(limit * 5); // Get more devices to filter + + if (devicesError || !devices) { + console.warn(`Error getting devices:`, devicesError?.message); + return []; + } + + // Get device types + const { data: deviceTypes, error: typesError } = await supabase + .from('cw_device_type') + .select('id, name, data_table_v2') + .eq('data_table_v2', tableName); + + if (typesError || !deviceTypes || deviceTypes.length === 0) { + console.warn(`No device types found for table ${tableName}:`, typesError?.message); + return []; + } + + // Filter devices by type and format response + const typeIds = deviceTypes.map((t) => t.id); + const matchingDevices = devices + .filter((device: any) => typeIds.includes(device.type)) + .slice(0, limit) + .map((device: any) => { + const deviceType = deviceTypes.find((t) => t.id === device.type); + return { + dev_eui: device.dev_eui as string, + name: device.name as string, + cw_device_type: { + name: deviceType?.name as string, + data_table_v2: deviceType?.data_table_v2 as string + } + }; + }); + + return matchingDevices; + } + + // Helper function to query data with created_at field + async function queryDeviceData( + tableName: string, + devEui: string, + startDate: Date, + endDate: Date + ) { + const utcStartDate = convertUserTimezoneToUTC(startDate, timezone); + const utcEndDate = convertUserTimezoneToUTC(endDate, timezone); + + const { data, error, count } = await supabase + .from(tableName) + .select('*', { count: 'exact' }) + .eq('dev_eui', devEui) + .gte('created_at', utcStartDate.toISOString()) + .lte('created_at', utcEndDate.toISOString()) + .order('created_at', { ascending: false }) + .limit(100); // Reasonable limit for testing + + // Type assertion for data records + return { + data: data as Array> | null, + error, + count + }; + } + + describe('Air Quality Sensor Tests (cw_air_data)', () => { + it('should find air quality devices and query data with created_at field', async () => { + // Arrange + const devices = await getDevicesByTable('cw_air_data'); + + if (devices.length === 0) { + console.warn('⚠️ No air quality devices found, skipping test'); + return; + } + + const testDevice = devices[0]; + const startDate = new Date(2025, 7, 1, 0, 0, 0, 0); // August 1st midnight Tokyo + const endDate = new Date(2025, 7, 31, 23, 59, 59, 999); // End of August + + // Act + const { data, error, count } = await queryDeviceData( + 'cw_air_data', + testDevice.dev_eui, + startDate, + endDate + ); + + // Assert + expect(error).toBeNull(); + expect(data).toBeDefined(); + expect(Array.isArray(data)).toBe(true); + + if (data && data.length > 0) { + // Verify the data structure includes created_at field + expect(data[0]).toHaveProperty('created_at'); + expect(data[0]).toHaveProperty('dev_eui'); + expect(data[0].dev_eui).toBe(testDevice.dev_eui); + + // Verify created_at timestamps are properly formatted + const sampleRecord = data[0]; + expect(sampleRecord.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + + console.log( + `✅ VERIFIED: Air sensor ${testDevice.dev_eui} - Found ${count} records with created_at field` + ); + } + }, 15000); + + it('should convert air sensor timestamps correctly for Tokyo timezone', async () => { + // Arrange + const devices = await getDevicesByTable('cw_air_data'); + + if (devices.length === 0) { + console.warn('⚠️ No air quality devices found, skipping timestamp test'); + return; + } + + const testDevice = devices[0]; + // Test a specific day to check timezone conversion + const testDate = new Date(2025, 7, 15, 0, 0, 0, 0); // August 15th midnight Tokyo + const endDate = new Date(2025, 7, 15, 23, 59, 59, 999); + + // Act + const { data, error } = await queryDeviceData( + 'cw_air_data', + testDevice.dev_eui, + testDate, + endDate + ); + + // Assert + expect(error).toBeNull(); + + if (data && data.length > 0) { + // Verify timezone conversion by checking UTC timestamps + const utcStartExpected = convertUserTimezoneToUTC(testDate, timezone); + const utcEndExpected = convertUserTimezoneToUTC(endDate, timezone); + + // Check that the first record's timestamp is within our expected range + const firstRecord = data[data.length - 1]; // Last in desc order = earliest + const recordTime = new Date(firstRecord.created_at); + + expect(recordTime.getTime()).toBeGreaterThanOrEqual(utcStartExpected.getTime()); + expect(recordTime.getTime()).toBeLessThanOrEqual(utcEndExpected.getTime()); + + console.log( + `✅ VERIFIED: Air sensor timezone conversion - UTC range ${utcStartExpected.toISOString()} to ${utcEndExpected.toISOString()}` + ); + } + }, 15000); + }); + + describe('Soil Moisture Sensor Tests (cw_soil_data)', () => { + it('should find soil sensors and verify created_at field usage', async () => { + // Arrange + const devices = await getDevicesByTable('cw_soil_data'); + + if (devices.length === 0) { + console.warn('⚠️ No soil moisture devices found, skipping test'); + return; + } + + const testDevice = devices[0]; + const startDate = new Date(2025, 7, 1, 0, 0, 0, 0); + const endDate = new Date(2025, 7, 31, 23, 59, 59, 999); + + // Act + const { data, error, count } = await queryDeviceData( + 'cw_soil_data', + testDevice.dev_eui, + startDate, + endDate + ); + + // Assert + expect(error).toBeNull(); + expect(data).toBeDefined(); + + if (data && data.length > 0) { + // Verify soil-specific fields and created_at + const sampleRecord = data[0]; + expect(sampleRecord).toHaveProperty('created_at'); + expect(sampleRecord).toHaveProperty('moisture'); + expect(sampleRecord).toHaveProperty('temperature_c'); + expect(sampleRecord).toHaveProperty('ph'); + expect(sampleRecord).toHaveProperty('ec'); + + console.log( + `✅ VERIFIED: Soil sensor ${testDevice.dev_eui} - Found ${count} records with soil data fields` + ); + } + }, 15000); + + it('should handle soil sensor early morning readings correctly', async () => { + // Arrange + const devices = await getDevicesByTable('cw_soil_data'); + + if (devices.length === 0) return; + + const testDevice = devices[0]; + // Test early morning reading (common for soil sensors) + const earlyMorning = new Date(2025, 7, 15, 5, 0, 0, 0); // 5 AM Tokyo + const endTime = new Date(2025, 7, 15, 7, 0, 0, 0); // 7 AM Tokyo + + // Act + const { data, error } = await queryDeviceData( + 'cw_soil_data', + testDevice.dev_eui, + earlyMorning, + endTime + ); + + // Assert + expect(error).toBeNull(); + + if (data && data.length > 0) { + // Verify UTC conversion: 5-7 AM Tokyo = 8-10 PM previous day UTC + const utcStart = convertUserTimezoneToUTC(earlyMorning, timezone); + expect(utcStart.getUTCHours()).toBe(20); // 8 PM UTC previous day + + console.log( + `✅ VERIFIED: Soil sensor early morning conversion - 5 AM Tokyo = ${utcStart.toISOString()}` + ); + } + }, 15000); + }); + + describe('Water Level Sensor Tests (cw_water_data)', () => { + it('should find water sensors and validate created_at field', async () => { + // Arrange + const devices = await getDevicesByTable('cw_water_data'); + + if (devices.length === 0) { + console.warn('⚠️ No water level devices found, skipping test'); + return; + } + + const testDevice = devices[0]; + const startDate = new Date(2025, 7, 1, 0, 0, 0, 0); + const endDate = new Date(2025, 7, 31, 23, 59, 59, 999); + + // Act + const { data, error, count } = await queryDeviceData( + 'cw_water_data', + testDevice.dev_eui, + startDate, + endDate + ); + + // Assert + expect(error).toBeNull(); + expect(data).toBeDefined(); + + if (data && data.length > 0) { + // Verify water-specific fields and created_at + const sampleRecord = data[0]; + expect(sampleRecord).toHaveProperty('created_at'); + expect(sampleRecord).toHaveProperty('deapth_cm'); + expect(sampleRecord).toHaveProperty('pressure'); + expect(sampleRecord).toHaveProperty('temperature_c'); + + console.log( + `✅ VERIFIED: Water sensor ${testDevice.dev_eui} - Found ${count} records with water data fields` + ); + } + }, 15000); + }); + + describe('Pulse Meter Tests (cw_pulse_meters)', () => { + it('should find pulse meters and verify created_at field', async () => { + // Arrange + const devices = await getDevicesByTable('cw_pulse_meters'); + + if (devices.length === 0) { + console.warn('⚠️ No pulse meter devices found, skipping test'); + return; + } + + const testDevice = devices[0]; + const startDate = new Date(2025, 7, 1, 0, 0, 0, 0); + const endDate = new Date(2025, 7, 31, 23, 59, 59, 999); + + // Act + const { data, error, count } = await queryDeviceData( + 'cw_pulse_meters', + testDevice.dev_eui, + startDate, + endDate + ); + + // Assert + expect(error).toBeNull(); + expect(data).toBeDefined(); + + if (data && data.length > 0) { + // Verify pulse meter fields and created_at + const sampleRecord = data[0]; + expect(sampleRecord).toHaveProperty('created_at'); + expect(sampleRecord).toHaveProperty('count'); + expect(sampleRecord).toHaveProperty('litersPerPulse'); + expect(sampleRecord).toHaveProperty('periodCount'); + + console.log( + `✅ VERIFIED: Pulse meter ${testDevice.dev_eui} - Found ${count} records with pulse data fields` + ); + } + }, 15000); + }); + + describe('Multi-Device Type Comparison', () => { + it('should verify all non-traffic devices use created_at field consistently', async () => { + // Arrange + const nonTrafficTables = ['cw_air_data', 'cw_soil_data', 'cw_water_data', 'cw_pulse_meters']; + + const results = []; + + // Act - Check each device type + for (const tableName of nonTrafficTables) { + const devices = await getDevicesByTable(tableName, 1); + + if (devices.length > 0) { + const testDevice = devices[0]; + const testDate = new Date(2025, 7, 15, 12, 0, 0, 0); // Noon Tokyo + const endDate = new Date(2025, 7, 15, 12, 59, 59, 999); + + const { data, error } = await queryDeviceData( + tableName, + testDevice.dev_eui, + testDate, + endDate + ); + + results.push({ + tableName, + deviceEui: testDevice.dev_eui, + hasData: data && data.length > 0, + error: error?.message, + usesCreatedAt: data && data.length > 0 && 'created_at' in data[0] + }); + } else { + results.push({ + tableName, + deviceEui: null, + hasData: false, + error: 'No devices found', + usesCreatedAt: null + }); + } + } + + // Assert + console.log('📊 DEVICE TYPE COMPARISON:'); + results.forEach((result) => { + console.log( + ` ${result.tableName}: Device ${result.deviceEui}, Uses created_at: ${result.usesCreatedAt}` + ); + + if (result.hasData) { + expect(result.usesCreatedAt).toBe(true); + expect(result.error).toBeUndefined(); + } + }); + + // At least one device type should have data for this test to be meaningful + const devicesWithData = results.filter((r) => r.hasData); + expect(devicesWithData.length).toBeGreaterThan(0); + }, 25000); + + it('should verify timezone conversion consistency across device types', async () => { + // Arrange + const testTime = new Date(2025, 7, 15, 15, 30, 0, 0); // 3:30 PM Tokyo + const utcExpected = convertUserTimezoneToUTC(testTime, timezone); + + // Act & Assert + const nonTrafficTables = ['cw_air_data', 'cw_soil_data', 'cw_water_data']; + + for (const tableName of nonTrafficTables) { + const devices = await getDevicesByTable(tableName, 1); + + if (devices.length > 0) { + const testDevice = devices[0]; + const { data, error } = await queryDeviceData( + tableName, + testDevice.dev_eui, + testTime, + new Date(testTime.getTime() + 3600000) // +1 hour + ); + + expect(error).toBeNull(); + + // All device types should use the same timezone conversion + // 3:30 PM Tokyo = 6:30 AM UTC + expect(utcExpected.getUTCHours()).toBe(6); + expect(utcExpected.getUTCMinutes()).toBe(30); + + console.log( + `✅ VERIFIED: ${tableName} timezone conversion consistent - 3:30 PM Tokyo = ${utcExpected.toISOString()}` + ); + } + } + }, 20000); + }); + + describe('Data Quality for Non-Traffic Devices', () => { + it('should verify realistic data patterns for sensor devices', async () => { + // Arrange + const devices = await getDevicesByTable('cw_air_data'); + + if (devices.length === 0) return; + + const testDevice = devices[0]; + const testDate = new Date(2025, 7, 15, 0, 0, 0, 0); + const endDate = new Date(2025, 7, 15, 23, 59, 59, 999); + + // Act + const { data, error } = await queryDeviceData( + 'cw_air_data', + testDevice.dev_eui, + testDate, + endDate + ); + + // Assert + expect(error).toBeNull(); + + if (data && data.length > 0) { + // Verify timestamps are in chronological order (descending) + let previousTime = new Date(data[0].created_at); + + data.slice(1, 5).forEach((record) => { + // Check first few records + const currentTime = new Date(record.created_at); + expect(currentTime.getTime()).toBeLessThanOrEqual(previousTime.getTime()); + previousTime = currentTime; + }); + + // Verify data contains reasonable sensor values + const recordsWithData = data.filter( + (record) => + record.temperature_c !== null || record.humidity !== null || record.pressure !== null + ); + + expect(recordsWithData.length).toBeGreaterThan(0); + + console.log( + `✅ VERIFIED: Air sensor data quality - ${recordsWithData.length}/${data.length} records have sensor values` + ); + } + }, 15000); + + it('should verify created_at timestamps are within expected UTC range', async () => { + // Arrange + const devices = await getDevicesByTable('cw_soil_data'); + + if (devices.length === 0) return; + + const testDevice = devices[0]; + const tokyoMidnight = new Date(2025, 7, 15, 0, 0, 0, 0); // August 15th midnight Tokyo + const tokyoNoon = new Date(2025, 7, 15, 12, 0, 0, 0); // August 15th noon Tokyo + + // Act + const { data, error } = await queryDeviceData( + 'cw_soil_data', + testDevice.dev_eui, + tokyoMidnight, + tokyoNoon + ); + + // Assert + expect(error).toBeNull(); + + if (data && data.length > 0) { + // Expected UTC range: August 14th 3 PM to August 15th 3 AM + const utcStart = convertUserTimezoneToUTC(tokyoMidnight, timezone); + const utcEnd = convertUserTimezoneToUTC(tokyoNoon, timezone); + + expect(utcStart.getUTCDate()).toBe(14); // Previous day + expect(utcStart.getUTCHours()).toBe(15); // 3 PM UTC + + expect(utcEnd.getUTCDate()).toBe(15); // Same day + expect(utcEnd.getUTCHours()).toBe(3); // 3 AM UTC + + // Verify actual data timestamps fall within this range + data.forEach((record) => { + const recordTime = new Date(record.created_at); + expect(recordTime.getTime()).toBeGreaterThanOrEqual(utcStart.getTime()); + expect(recordTime.getTime()).toBeLessThanOrEqual(utcEnd.getTime()); + }); + + console.log( + `✅ VERIFIED: Soil sensor UTC range - ${utcStart.toISOString()} to ${utcEnd.toISOString()}` + ); + } + }, 15000); + }); +}); diff --git a/src/lib/tests/PDFReportIntegration.test.ts b/src/lib/tests/PDFReportIntegration.test.ts new file mode 100644 index 00000000..9c336102 --- /dev/null +++ b/src/lib/tests/PDFReportIntegration.test.ts @@ -0,0 +1,355 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { createClient } from '@supabase/supabase-js'; +import { DateTime } from 'luxon'; + +/** + * PDF REPORT API INTEGRATION TESTS - TEMPERATURE DEVICE + * + * These tests validate the complete PDF report generation flow for temperature devices, + * ensuring proper timezone conversion throughout the entire process. + * + * Test Device: 373632336F32840A (Temperature sensor) + * Focus: created_at field timezone conversion in PDF report generation + */ + +describe('PDF Report API Integration Tests - Temperature Device', () => { + let supabase: ReturnType; + const tempDeviceEui = '373632336F32840A'; + const timezone = 'Asia/Tokyo'; + const baseUrl = 'http://localhost:5173'; + + beforeAll(() => { + // Use environment variables if available, otherwise use test credentials + const supabaseUrl = + process.env.PUBLIC_SUPABASE_URL || 'https://dpaoqrcfswnzknixwkll.supabase.co'; + const supabaseKey = + process.env.PUBLIC_SUPABASE_ANON_KEY || + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRwYW9xcmNmc3duemtuaXh3a2xsIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Nzc1MDAwMzAsImV4cCI6MTk5MzA3NjAzMH0.fYA8IMcfuO0g42prGg3h3q_DtlwvWLKfd6nIs5dqAf0'; + + supabase = createClient(supabaseUrl, supabaseKey); + }); + + // Helper function to make authenticated PDF API requests + async function fetchPDFReport( + devEui: string, + startDate: string, + endDate: string, + timezone: string + ) { + const url = `${baseUrl}/api/devices/${devEui}/pdf?start=${startDate}&end=${endDate}&timezone=${timezone}`; + + try { + const response = await fetch(url, { + headers: { + Accept: 'application/pdf' + // Note: In real scenario, you'd need JWT authentication + // 'Authorization': `Bearer ${jwtToken}` + } + }); + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + contentType: response.headers.get('content-type'), + contentLength: response.headers.get('content-length'), + body: response.ok ? await response.arrayBuffer() : await response.text() + }; + } catch (error) { + // If server is not running, return null to skip the test + if (error instanceof Error && error.message.includes('ECONNREFUSED')) { + console.warn('⚠️ Development server not running, skipping PDF API test'); + return null; + } + throw error; + } + } + + describe('Temperature Device Data Validation', () => { + it('should verify temperature device exists and has data', async () => { + // Arrange - Query temperature device directly + const { data: device, error: deviceError } = await supabase + .from('cw_devices') + .select('dev_eui, location_id') + .eq('dev_eui', tempDeviceEui) + .single(); + + // Assert device exists + expect(deviceError).toBeNull(); + expect(device).toBeDefined(); + expect(device?.dev_eui).toBe(tempDeviceEui); + + console.log(`✅ VERIFIED: Temperature device ${tempDeviceEui} exists`); + console.log(` Location ID: ${device?.location_id}`); + + // Check if device has recent data + const { data: recentData, error: dataError } = await supabase + .from('cw_air_data') + .select('created_at, temperature_c, humidity, pressure') + .eq('dev_eui', tempDeviceEui) + .order('created_at', { ascending: false }) + .limit(5); + + expect(dataError).toBeNull(); + + if (recentData && recentData.length > 0) { + console.log(`✅ VERIFIED: Found ${recentData.length} recent temperature records`); + recentData.forEach((record: any, index: number) => { + const tokyoTime = DateTime.fromISO(record.created_at as string).setZone(timezone); + console.log( + ` ${index + 1}. Tokyo: ${tokyoTime.toFormat('yyyy-MM-dd HH:mm:ss')}, Temp: ${record.temperature_c}°C` + ); + }); + } else { + console.warn('⚠️ No recent data found for temperature device'); + } + }, 10000); + + it('should validate temperature data timezone conversion for PDF reports', async () => { + // Arrange - Get a sample of temperature data + const sampleDate = DateTime.now().setZone(timezone).minus({ days: 7 }).toISODate(); // 7 days ago + + const startDt = DateTime.fromISO(sampleDate + 'T00:00:00', { zone: timezone }).startOf('day'); + const endDt = DateTime.fromISO(sampleDate + 'T23:59:59', { zone: timezone }).endOf('day'); + + // Helper function - same as DeviceDataService + function convertUserTimezoneToUTC(date: Date, timezone: string): Date { + if (timezone === 'UTC') return date; + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hour = date.getHours(); + const minute = date.getMinutes(); + const second = date.getSeconds(); + + const dt = DateTime.fromObject( + { + year, + month, + day, + hour, + minute, + second + }, + { zone: timezone } + ); + + return dt.toUTC().toJSDate(); + } + + const utcStartDate = convertUserTimezoneToUTC(startDt.toJSDate(), timezone); + const utcEndDate = convertUserTimezoneToUTC(endDt.toJSDate(), timezone); + + // Act - Query temperature data with timezone conversion + const { data, error } = await supabase + .from('cw_air_data') + .select('created_at, temperature_c, humidity, pressure') + .eq('dev_eui', tempDeviceEui) + .gte('created_at', utcStartDate.toISOString()) + .lte('created_at', utcEndDate.toISOString()) + .order('created_at', { ascending: true }); + + // Assert + expect(error).toBeNull(); + + if (data && data.length > 0) { + console.log(`✅ VERIFIED: Found ${data.length} temperature records for ${sampleDate}`); + + // Validate timezone conversion + data.slice(0, 3).forEach((record: any, index: number) => { + const utcTime = DateTime.fromISO(record.created_at as string); + const tokyoTime = utcTime.setZone(timezone); + + // Should be within the expected date in Tokyo timezone + expect(tokyoTime.toISODate()).toBe(sampleDate); + + console.log( + ` ${index + 1}. UTC: ${record.created_at} → Tokyo: ${tokyoTime.toFormat('yyyy-MM-dd HH:mm:ss')}, Temp: ${record.temperature_c}°C` + ); + }); + + // Validate temperature data quality + const avgTemp = + data.reduce((sum: number, record: any) => sum + (Number(record.temperature_c) || 0), 0) / + data.length; + expect(avgTemp).toBeGreaterThan(-50); // Reasonable temperature range + expect(avgTemp).toBeLessThan(60); + + console.log(`✅ VERIFIED: Temperature data quality - Avg: ${avgTemp.toFixed(1)}°C`); + } else { + console.warn(`⚠️ No temperature data found for ${sampleDate}`); + } + }, 15000); + }); + + describe('PDF Report API Endpoint Tests', () => { + it('should generate PDF report for temperature device with proper timezone handling', async () => { + // Arrange - Use a recent date range + const endDate = DateTime.now().setZone(timezone).minus({ days: 1 }).toISODate(); + const startDate = DateTime.now().setZone(timezone).minus({ days: 3 }).toISODate(); + + // Act + const response = await fetchPDFReport(tempDeviceEui, startDate!, endDate!, timezone); + + // Skip test if server not running + if (response === null) return; + + // Assert + if (response.status === 401) { + console.warn('⚠️ PDF API requires authentication, skipping content validation'); + expect(response.status).toBe(401); // Expected for unauthenticated request + return; + } + + if (response.ok) { + expect(response.contentType).toContain('application/pdf'); + expect(response.body).toBeInstanceOf(ArrayBuffer); + const pdfSize = (response.body as ArrayBuffer).byteLength; + expect(pdfSize).toBeGreaterThan(1000); // Should be a substantial PDF + + console.log(`✅ VERIFIED: PDF generated successfully`); + console.log(` Content Type: ${response.contentType}`); + console.log(` Size: ${pdfSize} bytes`); + } else { + console.warn(`⚠️ PDF generation failed: ${response.status} ${response.statusText}`); + console.warn(` Response: ${response.body}`); + } + }, 20000); + + it('should handle invalid date ranges gracefully for temperature device', async () => { + // Arrange - Invalid date range + const startDate = '2025-12-31'; // Future date + const endDate = '2025-12-31'; + + // Act + const response = await fetchPDFReport(tempDeviceEui, startDate, endDate, timezone); + + // Skip test if server not running + if (response === null) return; + + // Assert + if (response.status === 401) { + console.warn('⚠️ PDF API requires authentication, test passed for auth check'); + return; + } + + // Should handle no data gracefully + if (!response.ok) { + expect(response.status).toBeOneOf([404, 400]); // No data found or bad request + console.log( + `✅ VERIFIED: Proper error handling for invalid date range: ${response.status}` + ); + } + }, 10000); + + it('should validate PDF report date parameter format handling', async () => { + // Arrange - Test various date formats + const testCases = [ + { start: '2025-08-01', end: '2025-08-01', valid: true }, + { start: '2025-8-1', end: '2025-8-1', valid: false }, // Invalid format + { start: 'invalid-date', end: '2025-08-01', valid: false }, + { start: '2025-08-01', end: '2025-07-31', valid: false } // End before start + ]; + + for (const testCase of testCases) { + // Act + const response = await fetchPDFReport( + tempDeviceEui, + testCase.start, + testCase.end, + timezone + ); + + // Skip test if server not running + if (response === null) continue; + + // Assert + if (response.status === 401) { + console.warn('⚠️ Skipping date format test due to auth requirement'); + continue; + } + + if (testCase.valid) { + // Valid dates might return 404 (no data) or 200 (success) + expect([200, 404]).toContain(response.status); + } else { + // Invalid dates should return 400 (bad request) + expect(response.status).toBe(400); + } + + console.log( + `✅ Date format test: ${testCase.start} to ${testCase.end} → ${response.status} ${response.statusText}` + ); + } + }, 30000); + }); + + describe('PDF Report Timezone Edge Cases', () => { + it('should handle month boundary timezone conversion for temperature reports', async () => { + // Arrange - Date range spanning month boundary + const startDate = '2025-07-31'; // Last day of July + const endDate = '2025-08-01'; // First day of August + + // Test timezone conversion logic (same as PDF server) + let start = new Date(startDate); + let end = new Date(endDate); + + const userStartDate = DateTime.fromJSDate(start).setZone(timezone).startOf('day'); + const userEndDate = DateTime.fromJSDate(end).setZone(timezone).endOf('day'); + + const utcStartDate = userStartDate.toUTC().toJSDate(); + const utcEndDate = userEndDate.toUTC().toJSDate(); + + // Assert timezone conversion + expect(utcStartDate.getUTCMonth()).toBe(6); // July (0-indexed) + expect(utcEndDate.getUTCMonth()).toBe(7); // August (0-indexed) + + // Query data with this range + const { data, error } = await supabase + .from('cw_air_data') + .select('created_at, temperature_c') + .eq('dev_eui', tempDeviceEui) + .gte('created_at', utcStartDate.toISOString()) + .lte('created_at', utcEndDate.toISOString()) + .limit(10); + + expect(error).toBeNull(); + + if (data && data.length > 0) { + // Verify data spans the month boundary correctly + const tokyoDates = data.map((record: any) => { + const tokyoTime = DateTime.fromISO(record.created_at as string).setZone(timezone); + return tokyoTime.toISODate(); + }); + + const uniqueDates = [...new Set(tokyoDates)]; + console.log( + `✅ VERIFIED: Month boundary data spans ${uniqueDates.length} dates in Tokyo timezone` + ); + console.log(` Dates: ${uniqueDates.join(', ')}`); + } + }, 15000); + + it('should validate timezone parameter consistency across PDF components', () => { + // Arrange - Test different timezone parameters + const testTimezones = ['Asia/Tokyo', 'America/New_York', 'Europe/London', 'UTC']; + const testDate = '2025-08-01'; + + testTimezones.forEach((tz) => { + // Act - Simulate PDF server timezone conversion + const date = new Date(testDate); + const userDate = DateTime.fromJSDate(date).setZone(tz).startOf('day'); + const utcDate = userDate.toUTC().toJSDate(); + + // Assert + expect(utcDate).toBeInstanceOf(Date); + expect(utcDate.toISOString()).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + + console.log( + `✅ Timezone conversion: ${tz} ${testDate} 00:00 → UTC ${utcDate.toISOString()}` + ); + }); + }); + }); +}); diff --git a/src/lib/tests/PDFReportTimezone.test.ts b/src/lib/tests/PDFReportTimezone.test.ts new file mode 100644 index 00000000..bdc04790 --- /dev/null +++ b/src/lib/tests/PDFReportTimezone.test.ts @@ -0,0 +1,409 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { DateTime } from 'luxon'; +import { DeviceDataService } from '../services/DeviceDataService'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { DeviceDataRecord } from '../models/DeviceDataRecord'; +import type { ReportAlertPoint } from '../models/Report'; + +/** + * CRITICAL PDF REPORT TIMEZONE TESTS - TEMPERATURE DEVICES + * + * These tests ensure that PDF report generation for temperature devices + * properly handles timezone conversion for the created_at field. + * + * Test Device: 373632336F32840A (Temperature sensor) + * Key requirement: created_at timestamps must be converted properly between + * user timezone and UTC for accurate report generation. + */ + +describe('PDF Report Temperature Device Timezone Tests', () => { + let deviceDataService: DeviceDataService; + let mockSupabase: SupabaseClient; + + const tempDeviceEui = '373632336F32840A'; + const timezone = 'Asia/Tokyo'; + + // Mock temperature device data structure + const mockTempDevice = { + dev_eui: tempDeviceEui, + location_id: 'test-location-1', + cw_device_type: { + data_table_v2: 'cw_air_data', // Temperature sensors use air data table + device_type: 'air_sensor' + } + }; + + // Mock temperature data records + const mockTempData: DeviceDataRecord[] = [ + { + dev_eui: tempDeviceEui, + created_at: '2025-08-01T06:00:00+00:00', // UTC: Aug 1st 6AM = Tokyo: Aug 1st 3PM + temperature_c: 25.5, + humidity: 60.2, + pressure: 1013.25 + }, + { + dev_eui: tempDeviceEui, + created_at: '2025-08-01T03:00:00+00:00', // UTC: Aug 1st 3AM = Tokyo: Aug 1st 12PM + temperature_c: 28.3, + humidity: 58.7, + pressure: 1012.8 + }, + { + dev_eui: tempDeviceEui, + created_at: '2025-07-31T21:00:00+00:00', // UTC: Jul 31st 9PM = Tokyo: Aug 1st 6AM + temperature_c: 22.1, + humidity: 65.4, + pressure: 1014.1 + } + ]; + + beforeEach(() => { + // Mock Supabase client with temperature device responses + mockSupabase = { + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: mockTempDevice, + error: null + }), + gte: vi.fn().mockReturnValue({ + lte: vi.fn().mockReturnValue({ + order: vi.fn().mockResolvedValue({ + data: mockTempData, + error: null + }) + }) + }) + }) + }) + }), + rpc: vi.fn().mockResolvedValue({ + data: mockTempData, + error: null + }) + } as any; + + deviceDataService = new DeviceDataService(mockSupabase); + }); + + describe('PDF Report Date Range Processing', () => { + it('should properly convert PDF report date parameters to UTC for temperature device', () => { + // Arrange - Same logic as PDF server + const startDateParam = '2025-08-01'; + const endDateParam = '2025-08-01'; + + // Act - Convert dates like PDF server does + let startDate = new Date(startDateParam); + let endDate = new Date(endDateParam); + + // Convert to user timezone and include full day (PDF server logic) + const userStartDate = DateTime.fromJSDate(startDate).setZone(timezone).startOf('day'); + const userEndDate = DateTime.fromJSDate(endDate).setZone(timezone).endOf('day'); + + // Convert back to UTC for database queries + const utcStartDate = userStartDate.toUTC().toJSDate(); + const utcEndDate = userEndDate.toUTC().toJSDate(); + + // Assert - Check the conversion matches expected values + expect(utcStartDate.toISOString()).toBe('2025-07-31T15:00:00.000Z'); // Aug 1st 00:00 Tokyo = Jul 31st 15:00 UTC + expect(utcEndDate.toISOString()).toBe('2025-08-01T14:59:59.999Z'); // Aug 1st 23:59 Tokyo = Aug 1st 14:59 UTC + + console.log('✅ PDF Report Date Range Conversion:'); + console.log(` Tokyo Range: ${userStartDate.toISO()} to ${userEndDate.toISO()}`); + console.log(` UTC Range: ${utcStartDate.toISOString()} to ${utcEndDate.toISOString()}`); + }); + + it('should handle multi-day PDF report date ranges correctly', () => { + // Arrange - Multi-day report + const startDateParam = '2025-08-01'; + const endDateParam = '2025-08-05'; + + // Act + let startDate = new Date(startDateParam); + let endDate = new Date(endDateParam); + + const userStartDate = DateTime.fromJSDate(startDate).setZone(timezone).startOf('day'); + const userEndDate = DateTime.fromJSDate(endDate).setZone(timezone).endOf('day'); + + const utcStartDate = userStartDate.toUTC().toJSDate(); + const utcEndDate = userEndDate.toUTC().toJSDate(); + + // Assert + expect(utcStartDate.toISOString()).toBe('2025-07-31T15:00:00.000Z'); + expect(utcEndDate.toISOString()).toBe('2025-08-05T14:59:59.999Z'); + + // Should span 5 days in Tokyo time + const daysDiff = userEndDate.diff(userStartDate, 'days').days; + expect(Math.ceil(daysDiff)).toBe(5); + }); + }); + + describe('Temperature Device Data Timezone Conversion', () => { + it('should convert temperature data created_at timestamps to user timezone for PDF display', async () => { + // Arrange + const startDate = new Date('2025-07-31T15:00:00.000Z'); // UTC + const endDate = new Date('2025-08-01T14:59:59.999Z'); // UTC + + // Act - Get data like PDF server does + const result = await deviceDataService.getDeviceDataByDateRange( + tempDeviceEui, + startDate, + endDate, + timezone + ); + + // Assert + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + + // Verify timezone conversion for each record + if (result && result.length > 0) { + result.forEach((record) => { + // created_at should be converted to Tokyo timezone + const tokyoTime = DateTime.fromISO(record.created_at).setZone(timezone); + + // Should be August 1st in Tokyo timezone + expect(tokyoTime.year).toBe(2025); + expect(tokyoTime.month).toBe(8); // August + expect(tokyoTime.day).toBe(1); + + console.log( + `✅ Temperature Record: UTC ${record.created_at} → Tokyo ${tokyoTime.toFormat('yyyy-MM-dd HH:mm:ss')}, Temp: ${(record as any).temperature_c}°C` + ); + }); + } + }); + + it('should handle temperature device report data with alert points', async () => { + // Arrange - Report with temperature alert points + const startDate = new Date('2025-07-31T15:00:00.000Z'); + const endDate = new Date('2025-08-01T14:59:59.999Z'); + + const alertPoints: ReportAlertPoint[] = [ + { + id: 1, + data_point_key: 'temperature_c', + name: 'High Temperature Alert', + operator: 'gt', + value: 25.0, + min: 25.0, + max: null, + hex_color: '#ff0000', + created_at: '2025-07-31T00:00:00Z', + report_id: 'report-1', + user_id: 'user-1' + } + ]; + + // Act + const result = await deviceDataService.getDeviceDataForReport({ + devEui: tempDeviceEui, + startDate, + endDate, + timezone, + columns: alertPoints.map((p) => p.data_point_key), + ops: alertPoints.map((p) => p.operator || 'gt'), + mins: alertPoints.map((p) => p.min || p.value || 0), + maxs: alertPoints.map((p) => p.max || null), + intervalMinutes: 30 + }); + + // Assert + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + + // Verify the report data has proper timezone conversion + if (result && result.length > 0) { + result.forEach((record) => { + const tokyoTime = DateTime.fromISO(record.created_at).setZone(timezone); + expect(tokyoTime.month).toBe(8); // Should be August in Tokyo + + // Should have temperature data + expect((record as any).temperature_c).toBeDefined(); + expect(typeof (record as any).temperature_c).toBe('number'); + }); + } + }); + }); + + describe('PDF Report Data Sorting and Formatting', () => { + it('should sort temperature data by created_at for chronological PDF display', async () => { + // Arrange + const startDate = new Date('2025-07-31T15:00:00.000Z'); + const endDate = new Date('2025-08-01T14:59:59.999Z'); + + // Act + const deviceData = await deviceDataService.getDeviceDataByDateRange( + tempDeviceEui, + startDate, + endDate, + timezone + ); + + // Sort like PDF server does + if (deviceData) { + deviceData.sort((a, b) => { + const dateA = new Date(a.created_at).getTime(); + const dateB = new Date(b.created_at).getTime(); + return dateA - dateB; // Ascending + }); + + // Assert + expect(deviceData.length).toBeGreaterThan(0); + + // Verify chronological order + for (let i = 1; i < deviceData.length; i++) { + const prevTime = new Date(deviceData[i - 1].created_at).getTime(); + const currentTime = new Date(deviceData[i].created_at).getTime(); + expect(currentTime).toBeGreaterThanOrEqual(prevTime); + } + + console.log('✅ Temperature Data Chronological Order:'); + deviceData.forEach((record, index) => { + const tokyoTime = DateTime.fromISO(record.created_at).setZone(timezone); + console.log( + ` ${index + 1}. Tokyo: ${tokyoTime.toFormat('yyyy-MM-dd HH:mm:ss')}, Temp: ${(record as any).temperature_c}°C` + ); + }); + } + }); + + it('should validate temperature data fields for PDF table generation', async () => { + // Arrange + const startDate = new Date('2025-07-31T15:00:00.000Z'); + const endDate = new Date('2025-08-01T14:59:59.999Z'); + + // Act + const deviceData = await deviceDataService.getDeviceDataByDateRange( + tempDeviceEui, + startDate, + endDate, + timezone + ); + + // Assert + expect(deviceData).toBeDefined(); + + if (deviceData && deviceData.length > 0) { + const firstRecord = deviceData[0]; + + // Should have required fields for PDF generation + expect(firstRecord.created_at).toBeDefined(); + expect(firstRecord.dev_eui).toBe(tempDeviceEui); + + // Temperature-specific fields + expect(firstRecord.temperature_c).toBeDefined(); + expect(typeof firstRecord.temperature_c).toBe('number'); + + // Optional environmental fields + if (firstRecord.humidity !== undefined) { + expect(typeof firstRecord.humidity).toBe('number'); + } + if (firstRecord.pressure !== undefined) { + expect(typeof firstRecord.pressure).toBe('number'); + } + + console.log('✅ Temperature Data Fields Validation:'); + console.log(` Device: ${firstRecord.dev_eui}`); + console.log(` Timestamp: ${firstRecord.created_at}`); + console.log(` Temperature: ${firstRecord.temperature}°C`); + console.log(` Humidity: ${firstRecord.humidity}%`); + console.log(` Pressure: ${firstRecord.pressure} hPa`); + } + }); + }); + + describe('PDF Report Edge Cases', () => { + it('should handle timezone conversion across month boundaries for temperature reports', () => { + // Arrange - Date range that crosses month boundary in Tokyo timezone + const startDateParam = '2025-07-31'; // Last day of July + const endDateParam = '2025-08-01'; // First day of August + + // Act + let startDate = new Date(startDateParam); + let endDate = new Date(endDateParam); + + const userStartDate = DateTime.fromJSDate(startDate).setZone(timezone).startOf('day'); + const userEndDate = DateTime.fromJSDate(endDate).setZone(timezone).endOf('day'); + + const utcStartDate = userStartDate.toUTC().toJSDate(); + const utcEndDate = userEndDate.toUTC().toJSDate(); + + // Assert - UTC range should properly span the month boundary + expect(utcStartDate.getUTCMonth()).toBe(6); // July (0-indexed) + expect(utcStartDate.getUTCDate()).toBe(30); // July 30th UTC + + expect(utcEndDate.getUTCMonth()).toBe(7); // August (0-indexed) + expect(utcEndDate.getUTCDate()).toBe(1); // August 1st UTC + + console.log('✅ Month Boundary Timezone Conversion:'); + console.log(` Tokyo: Jul 31st 00:00 → UTC: Jul 30th 15:00`); + console.log(` Tokyo: Aug 1st 23:59 → UTC: Aug 1st 14:59`); + }); + + it('should handle empty temperature data gracefully in PDF reports', async () => { + // Arrange - Mock empty response + const emptySupabase = { + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: mockTempDevice, + error: null + }), + gte: vi.fn().mockReturnValue({ + lte: vi.fn().mockReturnValue({ + order: vi.fn().mockResolvedValue({ + data: [], // Empty data + error: null + }) + }) + }) + }) + }) + }), + rpc: vi.fn().mockResolvedValue({ + data: [], + error: null + }) + } as any; + + const emptyDataService = new DeviceDataService(emptySupabase); + + // Act + const result = await emptyDataService.getDeviceDataByDateRange( + tempDeviceEui, + new Date('2025-08-01T00:00:00Z'), + new Date('2025-08-01T23:59:59Z'), + timezone + ); + + // Assert + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(0); + }); + + it('should validate timezone parameter handling for different timezones', () => { + // Arrange - Test different timezone scenarios + const testTimezones = ['Asia/Tokyo', 'America/New_York', 'Europe/London', 'UTC']; + + const testDate = '2025-08-01'; + + testTimezones.forEach((tz) => { + // Act + const startDate = new Date(testDate); + const userStartDate = DateTime.fromJSDate(startDate).setZone(tz).startOf('day'); + const utcStartDate = userStartDate.toUTC().toJSDate(); + + // Assert + expect(utcStartDate).toBeInstanceOf(Date); + expect(utcStartDate.toISOString()).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + + console.log(`✅ ${tz}: ${testDate} 00:00 → UTC: ${utcStartDate.toISOString()}`); + }); + }); + }); +}); diff --git a/src/lib/tests/ReportPDFIntegration.test.ts b/src/lib/tests/ReportPDFIntegration.test.ts new file mode 100644 index 00000000..e711fe05 --- /dev/null +++ b/src/lib/tests/ReportPDFIntegration.test.ts @@ -0,0 +1,325 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { createClient } from '@supabase/supabase-js'; +import { DateTime } from 'luxon'; + +/** + * REPORT PDF INTEGRATION TESTS + * + * These tests verify that PDF report generation handles timezone conversion + * correctly for both traffic and non-traffic devices. This ensures reports + * show data in the user's timezone, not backwards patterns. + */ + +describe('Report PDF Integration Tests', () => { + let supabase: ReturnType; + const baseUrl = 'http://localhost:5173'; + const timezone = 'Asia/Tokyo'; + + // Test devices + const trafficDevEui = '110110145241600107'; // Traffic camera + const airDevEui = '2CF7F1C0630000AC'; // Air quality sensor (if available) + + beforeAll(() => { + const supabaseUrl = + process.env.PUBLIC_SUPABASE_URL || 'https://dpaoqrcfswnzknixwkll.supabase.co'; + const supabaseKey = + process.env.PUBLIC_SUPABASE_ANON_KEY || + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRwYW9xcmNmc3duemtuaXh3a2xsIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Nzc1MDAwMzAsImV4cCI6MTk5MzA3NjAzMH0.fYA8IMcfuO0g42prGg3h3q_DtlwvWLKfd6nIs5dqAf0'; + supabase = createClient(supabaseUrl, supabaseKey); + }); + + // Helper function to test PDF API endpoints + async function testPDFEndpoint( + devEui: string, + startDate: string, + endDate: string, + timezone: string + ) { + const url = `${baseUrl}/api/devices/${devEui}/pdf?start=${startDate}&end=${endDate}&timezone=${timezone}`; + + try { + const response = await fetch(url, { + headers: { + Accept: 'application/pdf' + // Add authentication if needed + } + }); + + if (response.status === 401) { + console.warn(`⚠️ Authentication required for PDF API, skipping test for ${devEui}`); + return null; + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return { + status: response.status, + contentType: response.headers.get('content-type'), + contentLength: response.headers.get('content-length'), + body: await response.arrayBuffer() + }; + } catch (error) { + if (error instanceof Error && error.message.includes('ECONNREFUSED')) { + console.warn('⚠️ Development server not running, skipping PDF API test'); + return null; + } + throw error; + } + } + + describe('PDF API Timezone Validation', () => { + it('should generate PDF for traffic device with proper timezone handling', async () => { + // Arrange + const startDate = '2025-08-01'; + const endDate = '2025-08-01'; + + // Act + const pdfResponse = await testPDFEndpoint(trafficDevEui, startDate, endDate, timezone); + + if (pdfResponse === null) return; // Skip if server not running or auth required + + // Assert + expect(pdfResponse.status).toBe(200); + expect(pdfResponse.contentType).toBe('application/pdf'); + expect(pdfResponse.body.byteLength).toBeGreaterThan(1000); // Should be a real PDF + + console.log( + `✅ VERIFIED: Traffic device PDF generated (${pdfResponse.body.byteLength} bytes)` + ); + }, 15000); + + it('should handle date range that spans timezone boundaries', async () => { + // Arrange - Test range that crosses midnight Tokyo time + const startDate = '2025-07-31'; // Day before in UTC + const endDate = '2025-08-01'; // Includes our critical record + + // Act + const pdfResponse = await testPDFEndpoint(trafficDevEui, startDate, endDate, timezone); + + if (pdfResponse === null) return; + + // Assert + expect(pdfResponse.status).toBe(200); + expect(pdfResponse.contentType).toBe('application/pdf'); + + console.log(`✅ VERIFIED: Cross-timezone PDF generated successfully`); + }, 15000); + + it('should validate PDF generation for non-traffic devices', async () => { + // Arrange - Test with air quality device + const startDate = '2025-08-15'; + const endDate = '2025-08-15'; + + // First check if this device exists and has data + const { data: deviceCheck } = await supabase + .from('cw_devices') + .select('dev_eui, type') + .eq('dev_eui', airDevEui) + .single(); + + if (!deviceCheck) { + console.warn(`⚠️ Device ${airDevEui} not found, skipping non-traffic PDF test`); + return; + } + + // Act + const pdfResponse = await testPDFEndpoint(airDevEui, startDate, endDate, timezone); + + if (pdfResponse === null) return; + + // Assert + expect(pdfResponse.status).toBe(200); + expect(pdfResponse.contentType).toBe('application/pdf'); + + console.log(`✅ VERIFIED: Non-traffic device PDF generated`); + }, 15000); + }); + + describe('Report Data Quality Validation', () => { + it('should verify report data contains timezone-converted timestamps', async () => { + // Arrange + const startDate = '2025-08-01'; + const endDate = '2025-08-01'; + + // Test direct database query to verify timezone handling + const { data: trafficData, error } = await supabase + .from('cw_traffic2') + .select('id, traffic_hour, people_count, car_count') + .eq('dev_eui', trafficDevEui) + .gte('traffic_hour', '2025-07-31T15:00:00Z') // UTC range for August 1st Tokyo + .lte('traffic_hour', '2025-08-01T14:59:59Z') + .order('traffic_hour', { ascending: true }) + .limit(5); + + // Assert + expect(error).toBeNull(); + expect(trafficData).toBeDefined(); + + if (trafficData && trafficData.length > 0) { + // Validate timezone conversion + trafficData.forEach((record: any) => { + const utcTime = DateTime.fromISO(record.traffic_hour as string); + const tokyoTime = utcTime.setZone(timezone); + + // Should be within August 1st Tokyo time + expect(tokyoTime.year).toBe(2025); + expect(tokyoTime.month).toBe(8); + expect(tokyoTime.day).toBe(1); + + console.log( + `🔍 Record ${record.id}: UTC ${record.traffic_hour} → Tokyo ${tokyoTime.toFormat('yyyy-MM-dd HH:mm:ss')}` + ); + }); + + // Verify we found our critical record + const criticalRecord = trafficData.find((r: any) => r.id === 35976); + if (criticalRecord) { + const criticalTime = DateTime.fromISO(criticalRecord.traffic_hour as string).setZone( + timezone + ); + expect(criticalTime.hour).toBe(0); // Should be midnight Tokyo + console.log(`✅ VERIFIED: Critical record ${criticalRecord.id} is at Tokyo midnight`); + } + } + }, 10000); + + it('should validate report data includes realistic traffic patterns', async () => { + // Arrange - Get full day of traffic data + const startDate = '2025-08-15'; + + const startDt = DateTime.fromISO(startDate + 'T00:00:00', { zone: timezone }).startOf('day'); + const endDt = DateTime.fromISO(startDate + 'T23:59:59', { zone: timezone }).endOf('day'); + + const utcStart = startDt.toUTC().toISO(); + const utcEnd = endDt.toUTC().toISO(); + + // Act + const { data: dayData, error } = await supabase + .from('cw_traffic2') + .select('traffic_hour, people_count, car_count') + .eq('dev_eui', trafficDevEui) + .gte('traffic_hour', utcStart) + .lte('traffic_hour', utcEnd) + .order('traffic_hour', { ascending: true }); + + // Assert + expect(error).toBeNull(); + expect(dayData).toBeDefined(); + + if (dayData && dayData.length > 0) { + // Analyze traffic patterns + const hourlyTraffic: { [hour: number]: number } = {}; + + dayData.forEach((record: any) => { + const utcTime = DateTime.fromISO(record.traffic_hour as string); + const tokyoTime = utcTime.setZone(timezone); + const hour = tokyoTime.hour; + + if (!hourlyTraffic[hour]) hourlyTraffic[hour] = 0; + hourlyTraffic[hour] += + ((record.people_count as number) || 0) + ((record.car_count as number) || 0); + }); + + // Calculate night vs day traffic + const nightHours = [0, 1, 2, 3, 4, 5]; + const dayHours = [9, 10, 11, 12, 13, 14, 15, 16, 17]; + + const nightTraffic = nightHours.reduce((sum, hour) => sum + (hourlyTraffic[hour] || 0), 0); + const dayTraffic = dayHours.reduce((sum, hour) => sum + (hourlyTraffic[hour] || 0), 0); + + // Should have realistic pattern (more traffic during day) + if (dayTraffic > 0 && nightTraffic >= 0) { + expect(dayTraffic).toBeGreaterThanOrEqual(nightTraffic); + console.log( + `✅ VERIFIED: Realistic traffic pattern - Day: ${dayTraffic}, Night: ${nightTraffic}` + ); + } + } + }, 15000); + + it('should verify non-traffic device data timezone conversion', async () => { + // Arrange - Test with air quality device data + const startDate = '2025-08-15'; + const endDate = '2025-08-15'; + + // Create UTC date range for database query + const startDt = DateTime.fromISO(startDate + 'T00:00:00', { zone: timezone }).startOf('day'); + const endDt = DateTime.fromISO(endDate + 'T23:59:59', { zone: timezone }).endOf('day'); + + const utcStart = startDt.toUTC().toISO(); + const utcEnd = endDt.toUTC().toISO(); + + // Act - Query air quality data (using created_at field) + const { data: airData, error } = await supabase + .from('cw_air_data') + .select('dev_eui, created_at, temperature_c, humidity') + .gte('created_at', utcStart) + .lte('created_at', utcEnd) + .order('created_at', { ascending: true }) + .limit(10); + + // Assert + expect(error).toBeNull(); + + if (airData && airData.length > 0) { + // Validate timezone conversion for non-traffic data + airData.forEach((record: any) => { + const utcTime = DateTime.fromISO(record.created_at as string); + const tokyoTime = utcTime.setZone(timezone); + + // Should be within the requested day in Tokyo timezone + expect(tokyoTime.year).toBe(2025); + expect(tokyoTime.month).toBe(8); + expect(tokyoTime.day).toBe(15); + + console.log( + `🔍 Air Record ${record.id}: UTC ${record.created_at} → Tokyo ${tokyoTime.toFormat('yyyy-MM-dd HH:mm:ss')}` + ); + }); + + console.log( + `✅ VERIFIED: ${airData.length} air quality records with proper timezone conversion` + ); + } else { + console.warn('⚠️ No air quality data found for test date, skipping validation'); + } + }, 10000); + }); + + describe('Report API Error Handling', () => { + it('should handle invalid date ranges gracefully', async () => { + // Arrange - Invalid date range + const startDate = '2025-12-31'; + const endDate = '2025-01-01'; // End before start + + // Act + const pdfResponse = await testPDFEndpoint(trafficDevEui, startDate, endDate, timezone); + + if (pdfResponse === null) return; + + // Assert - Should either return an error or handle gracefully + if (pdfResponse.status !== 200) { + expect(pdfResponse.status).toBeGreaterThanOrEqual(400); + console.log(`✅ VERIFIED: Invalid date range handled with status ${pdfResponse.status}`); + } + }, 10000); + + it('should handle non-existent device gracefully', async () => { + // Arrange + const fakeDevEui = 'NONEXISTENT123456789'; + const startDate = '2025-08-01'; + const endDate = '2025-08-01'; + + // Act + const pdfResponse = await testPDFEndpoint(fakeDevEui, startDate, endDate, timezone); + + if (pdfResponse === null) return; + + // Assert - Should return appropriate error + expect(pdfResponse.status).toBeGreaterThanOrEqual(400); + console.log(`✅ VERIFIED: Non-existent device handled with status ${pdfResponse.status}`); + }, 10000); + }); +}); diff --git a/src/lib/tests/ReportTimezone.test.ts b/src/lib/tests/ReportTimezone.test.ts new file mode 100644 index 00000000..13caafa9 --- /dev/null +++ b/src/lib/tests/ReportTimezone.test.ts @@ -0,0 +1,422 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { DateTime } from 'luxon'; +import { DeviceDataService } from '../services/DeviceDataService'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +/** + * CRITICAL REPORT TIMEZONE TESTS + * + * These tests ensure that report data loading and PDF generation handle + * timezone conversion correctly for both created_at (non-traffic) and + * traffic_hour (traffic) fields. + * + * Key requirements: + * - Report data should convert timestamps to user timezone + * - Both traffic and non-traffic devices should work correctly + * - PDF generation should use proper timezone-converted data + */ + +describe('Report Data Timezone Tests', () => { + let deviceDataService: DeviceDataService; + let mockSupabase: SupabaseClient; + + beforeEach(() => { + // Mock Supabase client with report-specific methods + mockSupabase = { + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { + cw_device_type: { data_table_v2: 'cw_air_data' } + }, + error: null + }) + }) + }) + }) + }), + rpc: vi.fn() + } as any; + + deviceDataService = new DeviceDataService(mockSupabase); + }); + + describe('getDeviceDataForReport timezone handling', () => { + it('should pass correct timezone parameters to stored procedure', async () => { + // Arrange + const mockRpcResponse = [ + { + id: 1, + dev_eui: '110110145241600107', + created_at: '2025-08-01T00:00:00+09:00', // Tokyo time + temperature_c: 25.5, + humidity: 60.2 + } + ]; + + (mockSupabase.rpc as any).mockResolvedValue({ + data: mockRpcResponse, + error: null + }); + + const startDate = new Date('2025-08-01T00:00:00.000Z'); // UTC + const endDate = new Date('2025-08-01T23:59:59.999Z'); // UTC + const timezone = 'Asia/Tokyo'; + + // Act + const result = await deviceDataService.getDeviceDataForReport({ + devEui: '110110145241600107', + startDate, + endDate, + timezone, + intervalMinutes: 30 + }); + + // Assert + expect(mockSupabase.rpc).toHaveBeenCalledWith( + 'get_filtered_device_report_data_multi', + expect.objectContaining({ + p_dev_id: '110110145241600107', + p_start_time: startDate, + p_end_time: endDate, + p_interval_minutes: 30 + }) + ); + + expect(result).toHaveLength(1); + expect(result[0].dev_eui).toBe('110110145241600107'); + }); + + it('should handle report with no data gracefully', async () => { + // Arrange + (mockSupabase.rpc as any).mockResolvedValue({ + data: [], + error: null + }); + + // Act + const result = await deviceDataService.getDeviceDataForReport({ + devEui: '110110145241600107', + startDate: new Date('2025-08-01'), + endDate: new Date('2025-08-01'), + timezone: 'Asia/Tokyo', + intervalMinutes: 30 + }); + + // Assert + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty('error'); + expect(result[0].error).toBe('No data found for the specified date range'); + }); + + it('should load alert points from device reports', async () => { + // Arrange + const mockReportsData = [ + { + report_id: 'test-report-1', + report_alert_points: [ + { + data_point_key: 'temperature_c', + operator: '>', + value: 30.0, + min: null, + max: null + }, + { + data_point_key: 'humidity', + operator: 'BETWEEN', + value: null, + min: 40.0, + max: 80.0 + } + ] + } + ]; + + // Mock reports query + const mockFrom = vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue({ + data: mockReportsData, + error: null + }) + }) + }) + }); + + (mockSupabase.from as any).mockImplementation((table: string) => { + if (table === 'reports') { + return mockFrom(); + } + // Default device type lookup + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { cw_device_type: { data_table_v2: 'cw_air_data' } }, + error: null + }) + }) + }) + }; + }); + + (mockSupabase.rpc as any).mockResolvedValue({ + data: [{ id: 1, dev_eui: '110110145241600107', temperature_c: 25.5 }], + error: null + }); + + // Act + await deviceDataService.getDeviceDataForReport({ + devEui: '110110145241600107', + startDate: new Date('2025-08-01'), + endDate: new Date('2025-08-01'), + timezone: 'Asia/Tokyo', + intervalMinutes: 30 + }); + + // Assert + expect(mockSupabase.rpc).toHaveBeenCalledWith( + 'get_filtered_device_report_data_multi', + expect.objectContaining({ + p_columns: ['temperature_c', 'humidity'], + p_ops: ['>', 'BETWEEN'], + p_mins: [30.0, 40.0], + p_maxs: [null, 80.0] + }) + ); + }); + + it('should handle database errors gracefully', async () => { + // Arrange + (mockSupabase.rpc as any).mockResolvedValue({ + data: null, + error: { message: 'Database connection failed' } + }); + + // Act & Assert + await expect( + deviceDataService.getDeviceDataForReport({ + devEui: '110110145241600107', + startDate: new Date('2025-08-01'), + endDate: new Date('2025-08-01'), + timezone: 'Asia/Tokyo', + intervalMinutes: 30 + }) + ).rejects.toThrow('Error fetching report data: Database connection failed'); + }); + }); + + describe('Report data timezone conversion validation', () => { + it('should validate timestamp formats in report data', () => { + // Arrange - Sample report data with different timestamp formats + const reportData = [ + { + id: 1, + created_at: '2025-08-01T00:00:00+09:00', // Tokyo timezone + temperature_c: 25.5 + }, + { + id: 2, + created_at: '2025-08-01T01:00:00+09:00', // Tokyo timezone + temperature_c: 26.0 + } + ]; + + // Act - Parse timestamps and convert to UTC + const parsedData = reportData.map((record) => { + const dt = DateTime.fromISO(record.created_at); + return { + ...record, + utc_time: dt.toUTC().toISO(), + tokyo_time: dt.setZone('Asia/Tokyo').toISO(), + is_valid: dt.isValid + }; + }); + + // Assert + parsedData.forEach((record) => { + expect(record.is_valid).toBe(true); + expect(record.utc_time).toMatch(/Z$/); // Should end with Z for UTC + expect(record.tokyo_time).toContain('+09:00'); // Should have Tokyo offset + }); + + // Validate specific conversion + const firstRecord = parsedData[0]; + expect(firstRecord.utc_time).toBe('2025-07-31T15:00:00.000Z'); // Tokyo midnight = July 31 3PM UTC + }); + + it('should handle traffic data timestamps correctly in reports', () => { + // Arrange - Traffic data uses traffic_hour field instead of created_at + const trafficReportData = [ + { + id: 35976, + traffic_hour: '2025-07-31T15:00:00+00:00', // UTC time + people_count: 0, + car_count: 6 + } + ]; + + // Act - Convert to Tokyo timezone for display + const convertedData = trafficReportData.map((record) => { + const dt = DateTime.fromISO(record.traffic_hour, { zone: 'UTC' }); + return { + ...record, + tokyo_display: dt.setZone('Asia/Tokyo').toFormat('yyyy-MM-dd HH:mm:ss'), + tokyo_iso: dt.setZone('Asia/Tokyo').toISO() + }; + }); + + // Assert + const firstRecord = convertedData[0]; + expect(firstRecord.tokyo_display).toBe('2025-08-01 00:00:00'); // Should be midnight Tokyo + expect(firstRecord.tokyo_iso).toContain('2025-08-01T00:00:00'); + expect(firstRecord.tokyo_iso).toContain('+09:00'); + }); + + it('should validate date range boundaries for reports', () => { + // Arrange - Test August 1st midnight Tokyo conversion + const timezone = 'Asia/Tokyo'; + const dateParam = '2025-08-01'; + + // Act - Create DateTime objects in user timezone (same as API logic) + const startDt = DateTime.fromISO(dateParam + 'T00:00:00', { zone: timezone }).startOf('day'); + const endDt = DateTime.fromISO(dateParam + 'T23:59:59.000', { zone: timezone }); + + // Convert to UTC for database query + const utcStart = startDt.toUTC(); + const utcEnd = endDt.toUTC(); + + // Assert + expect(utcStart.toISO()).toBe('2025-07-31T15:00:00.000Z'); // July 31 3PM UTC + expect(utcEnd.toISO()).toBe('2025-08-01T14:59:59.000Z'); // August 1 2:59PM UTC + + // Validate the range covers full day in Tokyo timezone + const rangeDuration = utcEnd.diff(utcStart, 'hours').hours; + expect(rangeDuration).toBeCloseTo(23.999, 2); // Should be almost 24 hours + }); + }); + + describe('PDF generation timezone handling', () => { + it('should prepare data correctly for PDF charts with timezone conversion', () => { + // Arrange - Sample report data + const reportData = [ + { id: 1, created_at: '2025-08-01T00:00:00+09:00', temperature_c: 25.5, humidity: 60 }, + { id: 2, created_at: '2025-08-01T01:00:00+09:00', temperature_c: 26.0, humidity: 58 }, + { id: 3, created_at: '2025-08-01T02:00:00+09:00', temperature_c: 24.8, humidity: 62 } + ]; + + // Act - Convert for chart display (same logic as PDF generation) + const chartData = reportData.map((record) => { + const dt = DateTime.fromISO(record.created_at); + return { + x: dt.setZone('Asia/Tokyo').toFormat('HH:mm'), // Display in Tokyo time + temperature: record.temperature_c, + humidity: record.humidity + }; + }); + + // Assert + expect(chartData[0].x).toBe('00:00'); // Midnight Tokyo + expect(chartData[1].x).toBe('01:00'); // 1 AM Tokyo + expect(chartData[2].x).toBe('02:00'); // 2 AM Tokyo + + // Data should be sequential and logical + expect(chartData).toHaveLength(3); + chartData.forEach((point) => { + expect(point.temperature).toBeGreaterThan(20); + expect(point.humidity).toBeGreaterThan(50); + }); + }); + + it('should handle empty report data for PDF generation', () => { + // Arrange + const emptyReportData: any[] = []; + + // Act - Process empty data (same as PDF generation logic) + const chartData = emptyReportData.map((record) => ({ + x: DateTime.fromISO(record.created_at).setZone('Asia/Tokyo').toFormat('HH:mm'), + temperature: record.temperature_c + })); + + // Assert + expect(chartData).toHaveLength(0); + }); + + it('should validate PDF metadata with correct timezone information', () => { + // Arrange + const timezone = 'Asia/Tokyo'; + const startDate = '2025-08-01'; + const endDate = '2025-08-31'; + + // Act - Generate PDF metadata (same as PDF generation) + const tokyoNow = DateTime.now().setZone(timezone); + const offsetMinutes = tokyoNow.offset; + const offsetHours = Math.floor(Math.abs(offsetMinutes) / 60); + const offsetMins = Math.abs(offsetMinutes) % 60; + const offsetSign = offsetMinutes >= 0 ? '+' : '-'; + const formattedOffset = `${offsetSign}${offsetHours.toString().padStart(2, '0')}${offsetMins.toString().padStart(2, '0')}`; + + const pdfMetadata = { + title: `Device Report - ${startDate} to ${endDate}`, + generatedAt: tokyoNow.toFormat('yyyy-MM-dd HH:mm:ss'), + timezone: timezone, + dateRange: `${startDate} - ${endDate}`, + utcOffset: formattedOffset + }; + + // Assert + expect(pdfMetadata.timezone).toBe('Asia/Tokyo'); + expect(pdfMetadata.utcOffset).toBe('+0900'); + expect(pdfMetadata.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + expect(pdfMetadata.title).toContain(startDate); + }); + }); + + describe('Alert point processing with timezone awareness', () => { + it('should process alert points correctly with timezone-converted data', () => { + // Arrange + const reportData = [ + { id: 1, created_at: '2025-08-01T14:00:00+09:00', temperature_c: 35.0 }, // Hot afternoon + { id: 2, created_at: '2025-08-01T02:00:00+09:00', temperature_c: 15.0 }, // Cool night + { id: 3, created_at: '2025-08-01T10:00:00+09:00', temperature_c: 25.0 } // Normal morning + ]; + + const alertPoints = [ + { data_point_key: 'temperature_c', operator: '>', value: 30.0, threshold: 'HIGH' }, + { data_point_key: 'temperature_c', operator: '<', value: 20.0, threshold: 'LOW' } + ]; + + // Act - Process alerts (same logic as report generation) + const alertResults = reportData.map((record) => { + const dt = DateTime.fromISO(record.created_at); + const alerts = alertPoints.filter((alert) => { + if (alert.operator === '>' && record.temperature_c > alert.value) return true; + if (alert.operator === '<' && record.temperature_c < alert.value) return true; + return false; + }); + + return { + ...record, + tokyo_time: dt.setZone('Asia/Tokyo').toFormat('yyyy-MM-dd HH:mm:ss'), + alerts: alerts, + alert_count: alerts.length + }; + }); + + // Assert + expect(alertResults[0].alerts).toHaveLength(1); // Hot afternoon should trigger HIGH alert + expect(alertResults[0].alerts[0].threshold).toBe('HIGH'); + expect(alertResults[0].tokyo_time).toBe('2025-08-01 14:00:00'); + + expect(alertResults[1].alerts).toHaveLength(1); // Cool night should trigger LOW alert + expect(alertResults[1].alerts[0].threshold).toBe('LOW'); + expect(alertResults[1].tokyo_time).toBe('2025-08-01 02:00:00'); + + expect(alertResults[2].alerts).toHaveLength(0); // Normal morning should trigger no alerts + }); + }); +}); diff --git a/src/routes/api/devices/[devEui]/csv/+server.ts b/src/routes/api/devices/[devEui]/csv/+server.ts index 3dddaf82..81fb7a57 100644 --- a/src/routes/api/devices/[devEui]/csv/+server.ts +++ b/src/routes/api/devices/[devEui]/csv/+server.ts @@ -87,8 +87,8 @@ export const GET: RequestHandler = async ({ let raw = (row as any)[field]; let value: string = ''; if (field === 'created_at' && raw) { - // Format for Excel: YYYY-MM-DD HH:mm:ss - const dt = DateTime.fromJSDate(new Date(raw)); + // Format for Excel: YYYY-MM-DD HH:mm:ss in requested timezone + const dt = DateTime.fromJSDate(new Date(raw)).setZone(userTimezone); value = dt.toFormat('yyyy-LL-dd HH:mm:ss'); } else if (raw != null) { value = String(raw); diff --git a/src/routes/api/devices/[devEui]/pdf/+server.ts b/src/routes/api/devices/[devEui]/pdf/+server.ts index fa71cb99..e2fdc564 100644 --- a/src/routes/api/devices/[devEui]/pdf/+server.ts +++ b/src/routes/api/devices/[devEui]/pdf/+server.ts @@ -13,7 +13,7 @@ import { DeviceDataService } from '$lib/services/DeviceDataService'; import { DeviceService } from '$lib/services/DeviceService'; import { LocationService } from '$lib/services/LocationService'; import { formatNumber, getColorNameByKey, getNumericKeys } from '$lib/utilities/stats'; -import { error, json } from '@sveltejs/kit'; +import { error as httpError, json } from '@sveltejs/kit'; import fs from 'fs'; import { DateTime } from 'luxon'; import path from 'path'; @@ -22,37 +22,38 @@ import { _ } from 'svelte-i18n'; import { get } from 'svelte/store'; import type { RequestHandler } from './$types'; -/** - * JWT-authenticated PDF generation endpoint for device data reports - * Designed for server-to-server calls (Node-RED, automation tools, etc.) - * - * Usage: - * GET /api/devices/{devEui}/pdf?start=2025-05-01&end=2025-06-06 - * Headers: Authorization: Bearer {jwt-token} - * - * Returns: PDF file as binary response - */ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) => { const { devEui } = params; + const tzOffsetPattern = /([zZ]|[+\-]\d{2}:\d{2}|[+\-]\d{4}|[+\-]\d{2})$/; + function parseDeviceInstant(input: string | Date, tz: string): DateTime { + if (input instanceof Date) { + return DateTime.fromJSDate(input, { zone: 'utc' }).setZone(tz); + } + let dt: DateTime; + if (tzOffsetPattern.test(input)) { + dt = DateTime.fromISO(input, { setZone: true }); + if (!dt.isValid) dt = DateTime.fromSQL(input, { setZone: true }); + return dt.setZone(tz); + } + dt = DateTime.fromISO(input, { zone: tz }); + if (!dt.isValid) dt = DateTime.fromSQL(input, { zone: tz }); + return dt; + } + try { - // const supabase = await validateAuth(request); - if (!supabase || supabase === null) { + if (!supabase) { return json({ error: 'Unauthorized access' }, { status: 401 }); } const { data: userData, error: userError } = await supabase.auth.getUser(); if (userError || !userData) { - console.error('Failed to get user from JWT:', userError?.message); return json( { error: `Unauthorized access - ${userError?.message}` }, { status: userError?.status } ); } const { user } = userData; - - if (!user) { - throw error(404, 'Device not found'); - } + if (!user) throw httpError(404, 'Device not found'); const { data: userProfile } = await supabase .from('profiles') @@ -64,7 +65,6 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) start: startDateParam, end: endDateParam, dataKeys: dataKeysParam = '', - alertPoints: alertPointsParam, locale: localeParam = 'ja', timezone: timezoneParam = 'Asia/Tokyo' } = Object.fromEntries(url.searchParams); @@ -74,43 +74,17 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) .map((key) => key.trim()) .filter(Boolean); - // const requestedAlertPoints = (() => { - // try { - // const points = alertPointsParam ? JSON.parse(alertPointsParam) : []; - - // if (!Array.isArray(points)) { - // throw new Error('alertPoints must be an array'); - // } - - // return points; - // } catch { - // return []; - // } - // })() as ReportAlertPoint[]; - // Pull alert points from the database if not provided - const { data: reportParams, error } = await supabase + const { data: reportParams } = await supabase .from('reports') .select('report_id, report_alert_points(*)') .eq('dev_eui', devEui) - .limit(1) // This intruduces a bug where it will ignore multiple reports.... + .limit(1) .single(); - console.log('alertPointsParamData', reportParams); - - if (error) { - console.error('Error fetching report parameters:', error.message); - return json( - { error: `Failed to fetch report parameters - ${error.message}` }, - { status: 404 } - ); - } let requestedAlertPoints: ReportAlertPoint[] = []; for (const point of reportParams?.report_alert_points || []) { - if (!point.data_point_key) { - console.warn(`Alert point with ID ${point.id} has no data_point_key, skipping this point`); - continue; - } - const pooint = { + if (!point.data_point_key) continue; + requestedAlertPoints.push({ created_at: point.created_at, data_point_key: point.data_point_key, hex_color: point.hex_color || '#ffffff', @@ -122,50 +96,28 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) report_id: point.report_id, user_id: point.user_id, value: point.value ?? 0 - }; - requestedAlertPoints.push(pooint as ReportAlertPoint); + }); } - - // Determine if this is a report or a specific data request const isReport = !requestedAlertPoints.length; - // Initialize i18n for localized strings await i18n.initialize({ initialLocale: localeParam }); const $_ = get(_); if (!startDateParam || !endDateParam) { - return json( - { - error: 'Missing required parameters: Both start and end dates are required', - example: '?start=2025-05-01&end=2025-06-06' - }, - { status: 400 } - ); + return json({ error: 'Missing start/end' }, { status: 400 }); } let startDate = new Date(startDateParam); let endDate = new Date(endDateParam); - // Validate dates - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { - return json( - { - error: 'Invalid date format: Dates must be in ISO format (YYYY-MM-DD)', - example: '?start=2025-05-01&end=2025-06-06' - }, - { status: 400 } - ); - } - - // Convert dates to user timezone and include full day const userStartDate = DateTime.fromJSDate(startDate).setZone(timezoneParam).startOf('day'); const userEndDate = DateTime.fromJSDate(endDate).setZone(timezoneParam).endOf('day'); + const displayStartLabel = userStartDate.toFormat('yyyy-MM-dd HH:mm'); + const displayEndLabel = userEndDate.toFormat('yyyy-MM-dd HH:mm'); - // Convert back to UTC for database queries startDate = userStartDate.toUTC().toJSDate(); endDate = userEndDate.toUTC().toJSDate(); - // Get device data using JWT-authenticated client (same method as browser version) const deviceDataService = new DeviceDataService(supabase); let deviceData: DeviceDataRecord[] = []; let alertPoints: ReportAlertPoint[] = requestedAlertPoints; @@ -174,195 +126,77 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) const deviceRepo = new DeviceRepository(supabase, errorHandler); const deviceService = new DeviceService(deviceRepo); const device = await deviceService.getDeviceWithTypeByEui(devEui); - - if (!device) { - throw error(404, 'Device not found'); - } - - const locationId = device.location_id; - - if (!locationId) { - throw error(400, 'Invalid location ID'); - } + if (!device) throw httpError(404, 'Device not found'); const locationRepo = new LocationRepository(supabase, errorHandler); const locationService = new LocationService(locationRepo, deviceRepo); - const location = await locationService.getLocationById(locationId); - - if (!location) { - throw error(404, 'Location not found'); - } - - try { - const deviceDataResponse = isReport - ? await deviceDataService.getDeviceDataForReport({ - devEui, - startDate, - endDate, - timezone: timezoneParam, - columns: requestedAlertPoints.map((point) => point.data_point_key as string), - ops: requestedAlertPoints.map((point) => point.operator as string), - mins: requestedAlertPoints.map((point) => (point.min ?? point.value) as number), - maxs: requestedAlertPoints.map((point) => point.max ?? null), - intervalMinutes: 30 // Default interval in minutes - }) - : await deviceDataService.getDeviceDataByDateRange( - devEui, - startDate, - endDate, - timezoneParam - ); - - if (deviceDataResponse && deviceDataResponse.length > 0) { - deviceData = deviceDataResponse; - - // Get alert points for the device if no specific keys are selected - if (!alertPoints.length) { - alertPoints = await deviceDataService.getAlertPointsForDevice(devEui); - } - } else { - throw new Error( - `No device data found for ${devEui} in the specified date range: ${startDate.toISOString()} to ${endDate.toISOString()}` - ); + const location = await locationService.getLocationById(device.location_id!); + + const deviceDataResponse = isReport + ? await deviceDataService.getDeviceDataForReport({ + devEui, + startDate, + endDate, + timezone: timezoneParam, + columns: requestedAlertPoints.map((p) => p.data_point_key as string), + ops: requestedAlertPoints.map((p) => p.operator as string), + mins: requestedAlertPoints.map((p) => (p.min ?? p.value) as number), + maxs: requestedAlertPoints.map((p) => p.max ?? null), + intervalMinutes: 30 + }) + : await deviceDataService.getDeviceDataByDateRange(devEui, startDate, endDate, timezoneParam); + + if (deviceDataResponse?.length) { + deviceData = deviceDataResponse; + if (!alertPoints.length) { + alertPoints = await deviceDataService.getAlertPointsForDevice(devEui); } - } catch { - //console.log('getDeviceDataForReport failed, continuing with empty data:', reportError instanceof Error ? reportError.message : 'Unknown error'); - deviceData = []; - } - - if (!deviceData || deviceData.length === 0) { - return json( - { - error: `No data found for device ${devEui} in the specified date range`, - device: devEui, - dateRange: { start: startDateParam, end: endDateParam }, - user: user?.email || 'Unknown user', - suggestion: 'Try a different date range or check if the device has been sending data' - }, - { status: 404 } - ); + } else { + return json({ error: 'No data' }, { status: 404 }); } - // Step 3: Sort the array by `created_at` deviceData.sort((a, b) => { - const dateA = new Date(a.created_at).getTime(); - const dateB = new Date(b.created_at).getTime(); - return dateA - dateB; // Ascending - }); - - // Generate professional PDF using PDFKit (same as browser version) - const doc = new PDFDocument({ - size: 'A4', - margin: 40, - info: { - Title: `Device ${devEui} Report`, - Author: `CropWatch API`, - Subject: `Data report for device ${devEui} from ${startDateParam} to ${endDateParam}`, - Creator: 'CropWatch API' - }, - bufferPages: true + const aMs = parseDeviceInstant(a.created_at as string, timezoneParam).toMillis(); + const bMs = parseDeviceInstant(b.created_at as string, timezoneParam).toMillis(); + return aMs - bMs; }); + const doc = new PDFDocument({ size: 'A4', margin: 40, bufferPages: true }); const { top: marginTop, right: marginRight, bottom: marginBottom, left: marginLeft } = doc.page.margins; - const contentWidth = doc.page.width - marginLeft - marginRight; - // Define possible font paths for NotoSansJP (Japanese font support) + // font const possibleFontPaths = [ path.join(process.cwd(), 'static/fonts/NotoSansJP-Regular.ttf'), - path.join(process.cwd(), 'src/lib/fonts/NotoSansJP-Regular.ttf'), - path.join(process.cwd(), 'server/fonts/NotoSansJP-Regular.ttf'), - 'static/fonts/NotoSansJP-Regular.ttf' + path.join(process.cwd(), 'src/lib/fonts/NotoSansJP-Regular.ttf') ]; - - // Try to load Japanese font - let fontLoaded = false; - for (const fontPath of possibleFontPaths) { - try { - if (fs.existsSync(fontPath)) { - //console.log(`Loading Japanese font from: ${fontPath}`); - doc.registerFont('NotoSansJP', fontPath); - doc.font('NotoSansJP'); - fontLoaded = true; - break; - } - } catch { - console.error(`Could not load font from: ${fontPath}`); + for (const fp of possibleFontPaths) { + if (fs.existsSync(fp)) { + doc.registerFont('NotoSansJP', fp); + doc.font('NotoSansJP'); + break; } } - if (!fontLoaded) { - console.error('Using default font - Japanese characters may not display correctly'); - } - - // Professional header with Japanese styling doc.fontSize(16).text(`CropWatch ${$_('device_report')}`); - - // Signature section - doc.fontSize(7).strokeColor('#ccc'); - doc.rect(400, marginTop, 50, 60).stroke(); - doc.rect(450, marginTop, 50, 60).stroke(); - doc.rect(500, marginTop, 50, 60).stroke(); - doc.text($_('created'), 405, 45); - doc.text($_('verified'), 455, 45); - doc.text($_('approved'), 505, 45); - - doc.x = marginLeft; - doc.y = 70; - - const metaTextOptions = { width: 320 }; - - // Report metadata doc .fontSize(8) - .text( - `${$_('generated_at')}: ${DateTime.now().setZone('Asia/Tokyo').toFormat('yyyy-MM-dd HH:mm:ss')}`, - metaTextOptions - ) - .text( - `${$_('generated_by')}: ${userProfile?.full_name || user.email || $_('Unknown')}`, - metaTextOptions - ); - - if (userProfile?.employer) { - doc.text(`${$_('Company')}: ${userProfile?.employer || $_('Unknown')}`, metaTextOptions); - } - - doc - .moveDown(0.5) - .text(`${$_('installed_at')}: ${location.name || $_('Unknown')}`, metaTextOptions) - // .text( - // `${$_('Device Type')}: ${device.cw_device_type?.name || $_('Unknown')}`, - // metaTextOptions - // ) - .text(`${$_('Device Name')}: ${device.name || $_('Unknown')}`, metaTextOptions) - .text(`${$_('EUI')}: ${devEui}`, metaTextOptions); - - doc.moveUp(2); - doc.x = 400; - - doc - .text(`${$_('date_range')}: ${startDateParam} - ${endDateParam}`) - .text(`${$_('sampling_size')}: ${deviceData.length}`); - - doc.x = marginLeft; - doc.moveDown(); + .text(`${$_('date_range')}: ${displayStartLabel} - ${displayEndLabel} (${timezoneParam})`); const numericKeys = getNumericKeys(deviceData); const validKeys = isReport || !selectedKeys.length ? numericKeys - : numericKeys.filter((key) => selectedKeys.includes(key)); + : numericKeys.filter((k) => selectedKeys.includes(k)); - // Only show columns in the final data table that participate in at least one alert const alertKeySet = new Set(alertPoints.map((p) => p.data_point_key)); const alertKeys = validKeys.filter((k) => alertKeySet.has(k)); - const tableKeys = alertKeys.length ? alertKeys : validKeys; // fallback if no alerts + const tableKeys = alertKeys.length ? alertKeys : validKeys; const keyColumns: TableCell[] = validKeys.map((key) => ({ label: $_(key), @@ -376,33 +210,33 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) cells: [...keyColumns, { label: $_('comment'), width: 40 }] }; + // ✅ Build rows with ISO+offset + JST label const getDataRows = (keys: string[] = validKeys): TableRow[] => deviceData.map((data, index) => { - const date = DateTime.fromISO(data.created_at).setZone('Asia/Tokyo'); - const previousData = index > 0 ? deviceData[index - 1] : null; - const previousDate = previousData - ? DateTime.fromISO(previousData.created_at).setZone('Asia/Tokyo') - : null; - const fullDateTime = date.toFormat('M/d H:mm'); + const date = parseDeviceInstant(data.created_at as string, timezoneParam); + const previousDate = + index > 0 + ? parseDeviceInstant(deviceData[index - 1].created_at as string, timezoneParam) + : null; + + const isoWithZone = date.toISO(); + const labelJst = date.toFormat('M/d H:mm'); + const shortJst = date.toFormat('H:mm'); return { header: { - value: new Date(data.created_at), - label: fullDateTime, - shortLabel: - // If the date is the same as the previous entry, show only time - previousDate?.toFormat('M/d') === date.toFormat('M/d') - ? date.toFormat('H:mm') - : fullDateTime + value: isoWithZone, // <— carry offset + label: labelJst, + shortLabel: previousDate?.toFormat('M/d') === date.toFormat('M/d') ? shortJst : labelJst }, cells: keys.map((key) => { - const rawValue = data[key]; - const value = typeof rawValue === 'number' && !isNaN(rawValue) ? rawValue : 0; - + const rawValue = (data as Record)[key]; + const value = + typeof rawValue === 'number' && !isNaN(rawValue as number) ? (rawValue as number) : 0; return { value, label: - typeof rawValue === 'number' && !isNaN(rawValue) + typeof rawValue === 'number' && !isNaN(rawValue as number) ? formatNumber({ key, value, adjustFractionDigits: true }) : '', bgColor: @@ -414,7 +248,6 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) } as TableRow; }); - // Prepare data rows for the table const dataRows = getDataRows(); const tableKeyColumns: TableCell[] = tableKeys.map((key) => ({ label: $_(key), @@ -428,255 +261,44 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) }; const dataRowsTable = getDataRows(tableKeys); - // Summary table - // Build classification + stats rows to replace the min/max/avg/stddev section per request - // Assumptions: - // 1. A "classification" is determined per data row across all alert points; if none match -> Normal. - // 2. Severity precedence (highest wins if multiple match): Alert > Warning > Notice > Normal. - // 3. We infer severity by alert point name (case-insensitive substring match). This is a best-effort heuristic. - // 4. Aggregate min/max/avg/stddev computed for the first valid key (primary metric) to present single values. - const severityOrder = ['alert', 'warning', 'notice']; - interface ClassificationCounts { - Normal: number; - Notice: number; - Warning: number; - Alert: number; - } - const classificationCounts: ClassificationCounts = { - Normal: 0, - Notice: 0, - Warning: 0, - Alert: 0 - }; - - const inferSeverity = ( - points: ReportAlertPoint[] - ): 'Alert' | 'Warning' | 'Notice' | 'Normal' => { - let found: 'Alert' | 'Warning' | 'Notice' | 'Normal' = 'Normal'; - for (const sev of severityOrder) { - if (points.some((p) => (p.name || '').toLowerCase().includes(sev))) { - found = (sev.charAt(0).toUpperCase() + sev.slice(1)) as 'Alert' | 'Warning' | 'Notice'; - break; - } - } - return found; - }; - - // Pre-index alert points by data key for quick matching - const alertPointsByKey = alertPoints.reduce>((acc, p) => { - const key = p.data_point_key as string; - if (!acc[key]) acc[key] = []; - acc[key].push(p); - return acc; - }, {}); - - // Iterate rows to classify - for (const row of dataRows) { - let rowSeverity: 'Alert' | 'Warning' | 'Notice' | 'Normal' = 'Normal'; - for (let i = 0; i < validKeys.length; i++) { - const key = validKeys[i]; - const value = row.cells[i].value as number; - const points = alertPointsByKey[key] || []; - const matched = points.filter((p) => checkMatch(value, p)); - if (matched.length) { - const severity = inferSeverity(matched); - // Upgrade severity if higher precedence - const precedence = { Normal: 0, Notice: 1, Warning: 2, Alert: 3 }; - if (precedence[severity] > precedence[rowSeverity]) rowSeverity = severity; - } - } - classificationCounts[rowSeverity]++; - } - - const totalDatapoints = dataRows.length; - const normalPercentage = totalDatapoints - ? (classificationCounts.Normal / totalDatapoints) * 100 - : 0; - const noticePercentage = totalDatapoints - ? (classificationCounts.Notice / totalDatapoints) * 100 - : 0; - const warningPercentage = totalDatapoints - ? (classificationCounts.Warning / totalDatapoints) * 100 - : 0; - const alertPercentage = totalDatapoints - ? (classificationCounts.Alert / totalDatapoints) * 100 - : 0; - - // Choose a primary key for single-value statistics (first numeric key) - const primaryKey = validKeys[0]; - const primaryValues = dataRows - .map((r) => r.cells[0].value as number) - .filter((v) => typeof v === 'number'); - const min = getValue(primaryValues, 'min'); - const max = getValue(primaryValues, 'max'); - const avg = getValue(primaryValues, 'avg'); - const stdDiv = getValue(primaryValues, 'stddev'); - const dateRange = `${startDateParam} - ${endDateParam}`; - - const statsRows = [ - `サンプリング数: ${totalDatapoints}`, - `測定期間: ${dateRange}`, - `Normal: ${classificationCounts.Normal} (${normalPercentage.toFixed(2)}%)`, - `Notice: ${classificationCounts.Notice} (${noticePercentage.toFixed(2)}%)`, - `Warning: ${classificationCounts.Warning} (${warningPercentage.toFixed(2)}%)`, - `Alert: ${classificationCounts.Alert} (${alertPercentage.toFixed(2)}%)`, - `最大値: ${max}`, - `最小値: ${min}`, - `平均値: ${avg.toFixed(2)}`, - `標準偏差: ${stdDiv.toFixed(2)}` - ]; - + // ✅ Pass timezone to createPDFDataTable createPDFDataTable({ doc, - config: { - caption: $_('summary') - }, - dataHeader: { - header: { label: $_('status'), width: 60 }, - cells: [...keyColumns, { label: $_('comment'), width: 60 }] - }, - dataRows: [ - ...alertPoints.map((alertPoint) => { - const { data_point_key, name, hex_color } = alertPoint; - - return { - header: { label: name, value: '' }, - cells: validKeys.map((key, index) => { - if (key !== data_point_key) { - return { label: '-', value: '' }; - } - - const valueList = dataRows.map((row) => row.cells[index].value as number); - const count = valueList.filter((value) => checkMatch(value, alertPoint)).length; - - return { - label: - `${new Intl.NumberFormat(localeParam, { maximumFractionDigits: 2 }).format(count)} ` + - `(${new Intl.NumberFormat(localeParam, { style: 'percent' }).format(count / valueList.length)})`, - value: count, - bgColor: hex_color || '#ffffff' - }; - }) - }; - }), - // Inject the requested stats rows (one per line) replacing the min/max/avg/stddev rows - ...statsRows.map((line) => ({ - header: { label: line, value: '' }, - cells: validKeys.map(() => ({ label: '', value: '' })) - })) - ] + dataHeader: dataHeaderTable, + dataRows: dataRowsTable, + config: { timezone: timezoneParam } }); - const chartWidth = contentWidth; - const chartHeight = contentWidth * 0.4; - - // Charts (restricted to the same alert-linked columns used for the data table) - const chartKeys = tableKeys; // tableKeys already falls back to validKeys if no alerts - for (const key of chartKeys) { - // Add a new page if the content exceeds the current page height - if (doc.y > doc.page.height - marginBottom - chartHeight + 20) { - doc.addPage(); - } else { - doc.moveDown(2); - } - - createPDFLineChartImage({ - doc, - dataHeader: { - header: { label: $_('datetime'), value: '', width: 60 }, - cells: [ - { - label: $_(key), - value: '', - width: 40, - color: getColorNameByKey(key) - } - ] - }, - dataRows: getDataRows([key]), - alertPoints, - config: { - title: $_(key), - width: chartWidth, - height: chartHeight - } - }); - } - - // After the last chart, force a page break before the full data table for clarity - if (chartKeys.length > 0) { - doc.addPage(); - } - // Position after page break (or just add spacing if no charts) - if (chartKeys.length === 0) { - doc.moveDown(2); - } - - // Full data table (restricted to alert-linked columns) - createPDFDataTable({ doc, dataHeader: dataHeaderTable, dataRows: dataRowsTable }); - const footerText = [ location.name, device.name, devEui, - `${startDateParam} - ${endDateParam}` + `${displayStartLabel} - ${displayEndLabel} (${timezoneParam})` ].join(' | '); - addFooterPageNumber(doc, footerText); - // 5) finalize doc.end(); - // Get the PDF as a buffer (async operation) const chunks: Buffer[] = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any doc.on('data', (chunk: any) => chunks.push(Buffer.from(chunk))); return new Promise((resolve, reject) => { doc.on('end', () => { const pdfBuffer = Buffer.concat(chunks); - - // Return the PDF with proper headers resolve( new Response(pdfBuffer, { status: 200, headers: { 'Content-Type': 'application/pdf', - 'Content-Disposition': `attachment; filename*=UTF-8''device-${devEui}-report-${startDateParam}-to-${endDateParam}.pdf`, - 'Content-Length': pdfBuffer.length.toString(), - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + 'Content-Disposition': `attachment; filename*=UTF-8''device-${devEui}-report.pdf`, + 'Content-Length': pdfBuffer.length.toString() } }) ); }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - doc.on('error', (err: any) => { - reject( - json( - { - error: 'Failed to generate PDF', - details: err.message, - device: devEui, - user: user?.email || 'Unknown user' - }, - { status: 500 } - ) - ); - }); + doc.on('error', (err: any) => reject(json({ error: err.message }, { status: 500 }))); }); } catch (err) { - console.error(`Error generating PDF for device ${devEui}:`, err); - return json( - { - error: 'Failed to generate PDF report', - details: err instanceof Error ? err.message : 'Unknown error', - device: devEui, - user: 'Unknown user' - }, - { status: 500 } - ); + return json({ error: (err as Error).message }, { status: 500 }); } }; diff --git a/src/routes/app/all-reports/+page.server.ts b/src/routes/app/all-reports/+page.server.ts index 4af32b45..312d7c84 100644 --- a/src/routes/app/all-reports/+page.server.ts +++ b/src/routes/app/all-reports/+page.server.ts @@ -4,39 +4,67 @@ import { SessionService } from '$lib/services/SessionService'; import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; import { ReportTemplateRepository } from '$lib/repositories/ReportTemplateRepository'; import { ReportTemplateService } from '$lib/services/ReportTemplateService'; +import { ReportService } from '$lib/services/ReportService'; +import { ReportAlertPointRepository } from '$lib/repositories/ReportAlertPointRepository'; +import { ReportRecipientRepository } from '$lib/repositories/ReportRecipientRepository'; +import { ReportUserScheduleRepository } from '$lib/repositories/ReportUserScheduleRepository'; +import { ReportRepository } from '$lib/repositories/ReportRepository'; export const load: PageServerLoad = async ({ locals: { supabase } }) => { - const sessionService = new SessionService(supabase); - const { session, user } = await sessionService.getSafeSession(); - if (!session || !user) { - throw redirect(302, '/auth/login'); - } + const sessionService = new SessionService(supabase); + const { session, user } = await sessionService.getSafeSession(); + if (!session || !user) { + throw redirect(302, '/auth/login'); + } - const repo = new ReportTemplateRepository(supabase, new ErrorHandlingService()); - const service = new ReportTemplateService(repo); - const reports = await service.getUserReports(user.id); + const reportRepository = new ReportRepository(supabase, new ErrorHandlingService()); + const reportTemplateRepository = new ReportTemplateRepository( + supabase, + new ErrorHandlingService() + ); + const repo = new ReportTemplateRepository(supabase, new ErrorHandlingService()); + const reportAlertPointRepository = new ReportAlertPointRepository( + supabase, + new ErrorHandlingService() + ); + const recipientRepository = new ReportRecipientRepository(supabase, new ErrorHandlingService()); + const reportUserScheduleRepository = new ReportUserScheduleRepository( + supabase, + new ErrorHandlingService() + ); + const reportService = new ReportService( + reportRepository, + reportAlertPointRepository, + recipientRepository, + reportUserScheduleRepository + ); + const service = new ReportTemplateService(repo); + const reports = await service.getUserReports(user.id); - return { reports }; + const allReports = await reportService.getAllReports(user.id); + debugger; + + return { allReports }; }; export const actions: Actions = { - delete: async ({ request, locals: { supabase } }) => { - const sessionService = new SessionService(supabase); - const { session, user } = await sessionService.getSafeSession(); - if (!session || !user) { - return fail(401, { success: false, error: 'Unauthorized' }); - } - - const data = await request.formData(); - const id = Number(data.get('id')); - - if (!id) { - return fail(400, { success: false, error: 'Missing id' }); - } - - const repo = new ReportTemplateRepository(supabase, new ErrorHandlingService()); - const service = new ReportTemplateService(repo); - await service.deleteReport(id); - return { success: true }; - } + delete: async ({ request, locals: { supabase } }) => { + const sessionService = new SessionService(supabase); + const { session, user } = await sessionService.getSafeSession(); + if (!session || !user) { + return fail(401, { success: false, error: 'Unauthorized' }); + } + + const data = await request.formData(); + const id = Number(data.get('id')); + + if (!id) { + return fail(400, { success: false, error: 'Missing id' }); + } + + const repo = new ReportTemplateRepository(supabase, new ErrorHandlingService()); + const service = new ReportTemplateService(repo); + await service.deleteReport(id); + return { success: true }; + } }; diff --git a/src/routes/app/all-reports/+page.svelte b/src/routes/app/all-reports/+page.svelte index 107048df..ad7217e7 100644 --- a/src/routes/app/all-reports/+page.svelte +++ b/src/routes/app/all-reports/+page.svelte @@ -14,7 +14,7 @@ } from '@mdi/js'; let { data } = $props(); - let reports = $state(data.reports as any[]); + let reports = $state(data.allReports as any[]); let searchTerm = $state(''); // Filter reports based on search term From 5545354b0d977c42457d6daaac212523158d9225 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Mon, 15 Sep 2025 11:30:33 +0900 Subject: [PATCH 09/18] safety! --- src/routes/app/all-reports/+page.server.ts | 1 - src/routes/app/all-reports/+page.svelte | 9 ++++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/routes/app/all-reports/+page.server.ts b/src/routes/app/all-reports/+page.server.ts index 312d7c84..6d8ebcc8 100644 --- a/src/routes/app/all-reports/+page.server.ts +++ b/src/routes/app/all-reports/+page.server.ts @@ -42,7 +42,6 @@ export const load: PageServerLoad = async ({ locals: { supabase } }) => { const reports = await service.getUserReports(user.id); const allReports = await reportService.getAllReports(user.id); - debugger; return { allReports }; }; diff --git a/src/routes/app/all-reports/+page.svelte b/src/routes/app/all-reports/+page.svelte index ad7217e7..91ca3ac7 100644 --- a/src/routes/app/all-reports/+page.svelte +++ b/src/routes/app/all-reports/+page.svelte @@ -10,8 +10,10 @@ mdiDelete, mdiPencil, mdiEye, - mdiPlus + mdiPlus, + mdiDownload } from '@mdi/js'; + import ExportButton from '$lib/components/devices/ExportButton.svelte'; let { data } = $props(); let reports = $state(data.allReports as any[]); @@ -111,7 +113,8 @@ > - + +
From 4bd22abe692ace88a73b913b2f6a8e2ea201004b Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Mon, 15 Sep 2025 15:43:07 +0900 Subject: [PATCH 10/18] almost there reports --- src/lib/repositories/BaseRepository.ts | 3 +- src/routes/app/all-reports/+page.server.ts | 1 - src/routes/app/all-reports/+page.svelte | 18 +-------- .../[devEui]/settings/reports/+page.svelte | 37 +++++++++++-------- .../settings/reports/create/+page.server.ts | 12 +++++- .../settings/reports/create/+page.svelte | 15 ++++++-- 6 files changed, 47 insertions(+), 39 deletions(-) diff --git a/src/lib/repositories/BaseRepository.ts b/src/lib/repositories/BaseRepository.ts index fe400c33..44993b29 100644 --- a/src/lib/repositories/BaseRepository.ts +++ b/src/lib/repositories/BaseRepository.ts @@ -103,9 +103,10 @@ export abstract class BaseRepository implements IRepository { async update(id: K, entity: U): Promise { const { data, error } = await this.supabase .from(this.tableName) - .update(entity) + .upsert(entity) .eq(this.primaryKey, id) .select() + .limit(1) .single(); if (error) { diff --git a/src/routes/app/all-reports/+page.server.ts b/src/routes/app/all-reports/+page.server.ts index 6d8ebcc8..988aa2fd 100644 --- a/src/routes/app/all-reports/+page.server.ts +++ b/src/routes/app/all-reports/+page.server.ts @@ -39,7 +39,6 @@ export const load: PageServerLoad = async ({ locals: { supabase } }) => { reportUserScheduleRepository ); const service = new ReportTemplateService(repo); - const reports = await service.getUserReports(user.id); const allReports = await reportService.getAllReports(user.id); diff --git a/src/routes/app/all-reports/+page.svelte b/src/routes/app/all-reports/+page.svelte index 91ca3ac7..00e6af2e 100644 --- a/src/routes/app/all-reports/+page.svelte +++ b/src/routes/app/all-reports/+page.svelte @@ -97,23 +97,7 @@ {report.name}
- - - +
+ +
+ +
+ + + +
+ +
+ + +
+
+ Clear + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
    +
    +
    +
    +
    + +
    +
    + +
    +
    + + +
    + + + +
    +
    +
    +

    + Provide feedback +

    + +
    +
    + +
    +
    + +
    + +
    +

    We read every piece of feedback, and take your input very seriously.

    + + + +
    +
    + +
    + + + + + +
    +
    +
    +

    + Saved searches +

    +

    Use saved searches to filter your results more quickly

    +
    +
    + +
    +
    + +
    + +
    + + + +
    +
    +
    + +
    +
    + +
    +
    +
    + + + + + + + +
    + + Appearance settings + + + +
    + + + + + + + + + + + +
    + + + + + + + + +
    + + + + + +
    + + + + + + + + + +
    +
    + + + +
    +
    + +
    +
    + 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
    +
    + +
    +
    + +
    + + +
    +
    + +
    + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + diff --git a/static/test-sample-report.pdf b/static/test-sample-report.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b1ff7cf7bec8625579834d8bb83186b42d0a990c GIT binary patch literal 6724 zcma)>2{@E(_rS+ij4auT#FKqDGt5Fr5!rXi7z{HQF=Gu`(rVwMvPWK`>{~?H$}Y)H zLW>qzvKIM2K(Z3>k)AQDLU6BK^{3j+m6C8Qhz17H;4au_8X zR9=HZ_5qc>0py?BrbKUwua_gy8$j&})YPCvGJ)!aI{xX+*o)$5M)U!&@?b=OygAX| z2awls2fd&8ao6~9*8zQKfj$6gkXi@;^Yf{oXE%zMnFrpHNSy=y{OyVw%THJOM6$Dw z3xH5Uf%YUfAEFl^ujPjKA!-sGDFh80`Xxi{Xzud?3Ie00n79%kNc zocESy*Q3fWPQ;BPOUzM|xf#@i{@Ah~sfAN3gSz1oU^?|kK9WCk4yV@8zfK^2PG~A4 zUS~L&vh0WsLDP~I5v}00Z5n%I65t@sX`Vey%!$5d)XE-ZqysPkB>%UeyjV1qFX~R zM4b>4;#3IRk%O2Eo>8e)-9bbA-^{(p(8HRous}*>Ce=UXN zR$4Q#-h+oAiwFSnIDD+;>--ZP;*2mrz-_JE??P2z8iT*vcd_(1e_R$lqXaPy{qjg1 zlKAb4ALR9EvJ2{huw7@#=a5b2S$2pGO=uj=vk=G%AB0T*Se_O10-I6HhT|EK|Z9Q4?^ICln>Jo1R-2FT8Xc4b*{p%QJ`2ec3w@qX!{rR=EQEC0(iwke%nlOB-ZFsHF@&lyt3MJsFBR7w z7iGVY@=Yy_tvGvPvn!DqxcYt~_%;6-T3RkH4M_=_Yb5EIcrsk|ba=tXA{eb%#dX!NDxJTOC3+%(lvzbzq`Ye=Q)ra!CDwcRR!q`P9Ih~Q_+EO}=cG-a! zk0Z&GC^O;ArZN8X6qEUMkz6ESKlz*51t+Oz592HSMmv-!X%$ku)m5H@x7fl(h-+1u zqxCzPmPMpxX!#ejo`|ySTx(qEw8QihSXiueQ~Uhlk9`j`nqd ztWPOtUvpfwg)3l+p54&Vdm-8&948~2@~kaU$al%)p%{OOosNj*hkm-FerYo2I|kl) ztp;4#XJ63go;`(^H^0bf@81>IPYcKi`m$(L%p};rI9P7rweD}m)HJ+}cw{noew)s+ z8ZkMm!{(83vK4OGQRV5V9poGNVd!zA4tLtjC;h^S!n;RDgo0{1Waka zu-B+(w2icNC$?Nr{4vY>`bsOmiZv8i=~+w}>kPNBjc@Cw<(xQV>i=*0#dU(Us0J?4EfUts8F>g@b%-{RMn|YieDu z9EG&(gQ!IeXVc%@5*t^X*>H!JDNG&vZe`S$8%JJH zd#D30Y$MEj5)!cL%*Rd55JgM})H%++gh2<13ogFZu^Wh*rGw0D92bn*M+w!X!V5>L z@AvnAvZzd-91bc-(vw@4SY6)8wksk(vo;RgDXpMmmw-HDapQT4mC$)vtE^O1Ad@U5 zrT0$DuE}XyyjLyuqj%2Y+xpPkdm*->2p|WL9Z)fFbI{i;xqElXGporJdC(2z_A=To-eOa}G4TFP?>8g1m^ii!t%TF% zlvyOXQu^qd10h#FyPl=QQ3Ny^Xz;#ALpC*E=Ar6mhtEVmm`$Kru{xr_gLd_I3)=}~AxzQ7}fG@-1dyi5M2=ttq^??;tyT!wo^H<{HO%bD#A zDJ>Yfs{PUYGc<3E7kBffo06&vQ(DiFA|_T?Y(cJPm&**S?>0M@ zUA@lnhjjeGwF4%+7U?l;Uv(q(490Y^mbrCW478WWL^jCZD^?pm zw)pA82j;+|d<%9~wdmwJKm7FfCzrIg_m{YfA3D(EY^CNRv?n>atn*y2rny_K&Vu7c zW!L0eK9~EkRC25n^nIlM)R_)ZWZeD-dq^3FlS>7 zTHL>!dmeyDMRprw=d??Lq^st35f7Y6x$!D&!OwJgi?$JLOV_Rt(%AgURfGzqJ z8*3ZC>@<|bko88R<@ic%)c`53B4)o|aLi&sUh$Z~IqU0Bno5N`XfN%D_{e7Ul=8nN zq4*;-15%x}zJJJ6AG#h;z+AE9pxW?Ygn_XTjFHZcZmPQEOJc)Sk%wLH(M#l(ljS2G z*6(WeiL3UouCLe+Gv0^|tm~l}ZZh|h8Uvf(CMJwqE9>^lPV6C@%t}>9%Ijvye5KEf zwcloYgrh4(M!4Nl8EI*a$aKM1N{$0|Y&G4r9pf3grq@QXb+L#oy9m6Q@o?+dKu~f+ zhVw@^i&b)97A_|uPlm;@X@el6l6ua+PK=wZr%x$gEjEnK)On!1p+59Jju0b&b1AL} zzWq`)XuW+VkIvSl){Ch%A-mvF3syI`XFPkmv?@Qpsu-T^`moMtdpbZyNOvxzh5PQ4#lYC@mICa0IKw1)-)occx&7EeTi#5QGhEy! zQ%&tuabMrQhF`s{FEyOPKeC=G_1rfm(TQo0|6UW%^o_wl{#e_i%jCv`0$ijwouaSe zuAcG=Kt*Z8TtC>~yIWQ!nezT~qxA!i;MGs74BOA2vZ)3gX0++urXz`P#9y?zmrG}v zns;AOx|6jGG1Rrux6T;(nSIUuJMHY_w|(KSpLA^`8APjnV*j4YdB?sNCsvYx0ybr( znvRg0t=d@3ReCtwc~drFH$+T0r~! zU7ezNcLA}*=-&Co4!P;RxIJV?dM;uoVFI`C|SewyHiuRd0U#-TL19A@}7D7QVPi+$agO z9Z^_M)}Oum{rldy{8@!}^{lE&xR=KzA||pjNUoU5r#Eg|$Nuq^|GP%uc-Chc+N1|r zNnwlMjG0W@A^ThugKUJ#Q#~*sa>P6DJHRjTztv>0QR8XstQ( z244?e`(k3+f7`VGqILhQDeuV+f?esg&q-Wnx#7*V(fuEhiU}#5PeiVBNh81N#+dp) z%F)W7&%&lp6%CB&kpg0@kNM1vObDOb@1d3a+d zE_UY((JbYL=Nr*c$0EO}#(aIVu4l<1F*$zkiw7NX@Aejx5AQ44SYMwLoG+38V^T)6qW=1_ z|I{hFhqZ}G$Id7`tifgB+4y5-^sXNluu_^f%1pmYuGSZb^E6XH9?XSnmTC&AID8cR zTqWXq;J|jM32I&7y(rhgwrgauBiyS|`4UgUk*>atdrA|+r6^p}##x$P5WW40*^*2; zdo6oqhDIi|V+9L&B@d|t3}@-X^iLG&xYXw68y)UUNvWTxl#3~rIvW(?Wc+S@RIi3B zoltiAQ^UD!jm6F0?G&EI^QEZd+mkI#7aPme+b6ms7G&3gj@q667)7aBLCQ_X&cp2p z=vNma_IENHJyv}5h5l=KPoBW(lv9y}NREu=gTAl2(hlo0oZuB=pa;KY1PhjMht%Q3 z+gKz%u-&>m&XJKa^^fDr9->LGPRo5!p`N+~ETYaOQp_YdVRP=`M zd>5Ez=J=y$nHZ;qVTQ361-=-^8D6AeVsB1@u`WPfb-^y<*qht3)urZ|^6`5FLp}Lv z1xP^DB{#gYH=y{Z_K98c zB@2ha5CHhDhyh6w$iLti7zQL$IzD(elH+l*vl|hBLFJEoJMMxrI0_t4{ZdD=2n+_~ z6?l(RM3S?M4}exsfXZ9_7$6nFfte4{-I8ig4HsloLJ`2qhglb$p7AG08UK>NNG+}p&=IBJQ#pdzyJyu z41j{602BfSV9*!eb zA^&fCNH|HGRS0tFy2U>u--MI{W5 z$~tv4%_-VCng)0ekWT*CjbAC#uZZ&}Wl}_gyYpYTBhFNnav8=xxyg;L)}U{q;b1yq zLBP+2=_*(pjtV z`P%L0b}^ezc&jeUkoe*Y5-Wq>zP;MVNOGC<<)})VsIITv-fn&B#J>D^ol8fC<8%vy zb+NYo2br$&KP+|ciXE5T=_n%^Uog_hAS$621M>IsWOveCk32{^d9qEFo2|9rwn`$_ zC&K!;xSoT!SH#dM^~`9__$TdGO#cw>mKZzY`z?>N;Z8MMD*-<`HSY{ZKp zC(gXdx%jo+NR1)qh@mDp-p8DDN z1U%TwU_W|;^}ox;!2&X;Sdd9n>I}gDlaBv#cKywNg*X7=_>T zkTB$LdPqg|FESr5Jop#EYxhSQGtzmmbif0}ltS?VsMP_UvAbtCm6cOJYXVpmqyq+p zMiZUz7)2P8=!C{NU~g7WDrB DaD@F1 literal 0 HcmV?d00001 From fd5fbb3d48d368fae82ae8bb656108e9c6cca44f Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Mon, 15 Sep 2025 23:39:12 +0900 Subject: [PATCH 13/18] perfect layout for reports --- .../api/devices/[devEui]/pdf/+server.ts | 113 ++++++++-------- .../[devEui]/pdf/drawRightAlertPanel.ts | 82 ++++++++++++ .../devices/[devEui]/pdf/drawSummaryPanel.ts | 121 ++++++++++++++++++ 3 files changed, 263 insertions(+), 53 deletions(-) create mode 100644 src/routes/api/devices/[devEui]/pdf/drawRightAlertPanel.ts create mode 100644 src/routes/api/devices/[devEui]/pdf/drawSummaryPanel.ts diff --git a/src/routes/api/devices/[devEui]/pdf/+server.ts b/src/routes/api/devices/[devEui]/pdf/+server.ts index ab8ead66..7dfa91e3 100644 --- a/src/routes/api/devices/[devEui]/pdf/+server.ts +++ b/src/routes/api/devices/[devEui]/pdf/+server.ts @@ -21,6 +21,8 @@ import PDFDocument from 'pdfkit'; import { _ } from 'svelte-i18n'; import { get } from 'svelte/store'; import type { RequestHandler } from './$types'; +import { drawSummaryPanel } from './drawSummaryPanel'; +import { drawRightAlertPanel } from './drawRightAlertPanel'; const tzOffsetPattern = /([zZ]|[+\-]\d{2}:\d{2}|[+\-]\d{4}|[+\-]\d{2})$/; /** @@ -271,19 +273,19 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) const metaTextOptions = { width: 320 }; // Metadata block - doc - .fontSize(8) - .text( - `${$_('generated_at')}: ${DateTime.now().setZone(timezoneParam).toFormat('yyyy-MM-dd HH:mm:ss')}`, - metaTextOptions - ) - .text( - `${$_('generated_by')}: ${userProfile?.full_name || user.email || $_('Unknown')}`, - metaTextOptions - ); - if (userProfile?.employer) { - doc.text(`${$_('Company')}: ${userProfile?.employer || $_('Unknown')}`, metaTextOptions); - } + // doc + // .fontSize(8) + // .text( + // `${$_('generated_at')}: ${DateTime.now().setZone(timezoneParam).toFormat('yyyy-MM-dd HH:mm:ss')}`, + // metaTextOptions + // ) + // .text( + // `${$_('generated_by')}: ${userProfile?.full_name || user.email || $_('Unknown')}`, + // metaTextOptions + // ); + // if (userProfile?.employer) { + // doc.text(`${$_('Company')}: ${userProfile?.employer || $_('Unknown')}`, metaTextOptions); + // } doc .moveDown(0.5) .text(`${$_('installed_at')}: ${location.name || $_('Unknown')}`, metaTextOptions) @@ -295,11 +297,11 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) .text(`${$_('EUI')}: ${devEui}`, metaTextOptions); // Right side quick facts - doc.moveUp(2); - doc.x = 400; - doc - .text(`${$_('date_range')}: ${startLabel} - ${endLabel} (${timezoneParam})`) - .text(`${$_('sampling_size')}: ${deviceData.length}`); + // doc.moveUp(2); + // doc.x = 400; + // doc + // .text(`${$_('date_range')}: ${startLabel} - ${endLabel} (${timezoneParam})`) + // .text(`${$_('sampling_size')}: ${deviceData.length}`); doc.x = marginLeft; doc.moveDown(); @@ -382,44 +384,49 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) }; const dataRowsTable = getDataRows(tableKeys); - // Summary table (old behavior) - createPDFDataTable({ + // LEFT summary + const primaryKey = tableKeys[0] ?? validKeys[0] ?? 'temperature_c'; + const { firstRowY, bottomY: summaryBottomY } = drawSummaryPanel(doc, { + dataRows, + validKeys, + primaryKey, + caption: $_('summary'), + samplingLabel: $_('sampling_size'), + dateRangeLabel: $_('date_range'), + maxLabel: $_('max'), + minLabel: $_('min'), + avgLabel: $_('avg'), + stddevLabel: $_('stddev'), + displayStartLabel: startLabel, + displayEndLabel: endLabel, + locale: localeParam, + tableWidthPercent: 0.5, + lineColor: '#000' + }); + + // RIGHT alerts — align exactly with “サンプリング数” + const panelX = 400; + const panelW = 180; + const rightBottomY = drawRightAlertPanel({ doc, - config: { caption: $_('summary'), timezone: timezoneParam }, - dataHeader: { - header: { label: $_('status'), width: 60 }, - cells: [...keyColumns, { label: $_('comment'), width: 60 }] - }, - dataRows: [ - ...alertPoints.map((alertPoint) => { - const { data_point_key, name, hex_color } = alertPoint; - return { - header: { label: name, value: '' }, - cells: validKeys.map((key, index) => { - if (key !== data_point_key) return { label: '-', value: '' }; - const valueList = dataRows.map((row) => row.cells[index].value as number); - const count = valueList.filter((v) => checkMatch(v, alertPoint)).length; - return { - label: - `${new Intl.NumberFormat(localeParam, { maximumFractionDigits: 2 }).format(count)} ` + - `(${new Intl.NumberFormat(localeParam, { style: 'percent' }).format(count / valueList.length)})`, - value: count, - bgColor: hex_color || '#ffffff' - }; - }) - }; - }), - ...['min', 'max', 'avg', 'stddev'].map((indicator) => ({ - header: { label: $_(indicator), value: '' }, - cells: validKeys.map((key, index) => { - const valueList = dataRows.map((row) => row.cells[index].value as number); - const value = getValue(valueList, indicator); - return { label: formatNumber({ key, value }), value }; - }) - })) - ] + x: panelX, + y: firstRowY, // ← precise alignment with the first row + width: panelW, + locale: localeParam, + startLabel: startLabel, + endLabel: endLabel, + timezone: timezoneParam, + samplingLabel: $_('sampling_size'), + sampleCount: deviceData.length, + alertPoints, + validKeys, + dataRows }); + // Normalize flow so charts start below both blocks + doc.x = doc.page.margins.left; + doc.y = Math.max(summaryBottomY, rightBottomY) + 12; + // Charts (keep old layout behavior) const chartWidth = contentWidth; const chartHeight = contentWidth * 0.4; diff --git a/src/routes/api/devices/[devEui]/pdf/drawRightAlertPanel.ts b/src/routes/api/devices/[devEui]/pdf/drawRightAlertPanel.ts new file mode 100644 index 00000000..a9258055 --- /dev/null +++ b/src/routes/api/devices/[devEui]/pdf/drawRightAlertPanel.ts @@ -0,0 +1,82 @@ +// src/lib/pdf/drawRightAlertPanel.ts +import type PDFDocument from 'pdfkit'; +import type { TableRow } from '$lib/pdf'; +import type { ReportAlertPoint } from '$lib/models/Report'; +import { checkMatch } from '$lib/pdf/utils'; + +export function drawRightAlertPanel(opts: { + doc: PDFDocument; + x: number; // left of the panel (e.g., 400) + y: number; // top of the panel (align with header) + width: number; // e.g., 180–200 + locale: string; + startLabel: string; + endLabel: string; + timezone: string; + samplingLabel: string; + sampleCount: number; + alertPoints: ReportAlertPoint[]; + validKeys: string[]; + dataRows: TableRow[]; +}): number { + const { + doc, + x, + y, + width, + locale, + startLabel, + endLabel, + timezone, + samplingLabel, + sampleCount, + alertPoints, + validKeys, + dataRows + } = opts; + + const savedX = doc.x; + const savedY = doc.y; + + const pf0 = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 }); + const nf0 = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }); + + doc.fontSize(8); + + let cy = y; + let bottomY = y; + + // Header lines + // cy = Math.max(cy, doc.y); // small gap + + // Alert points (count + % for their key) + for (const p of alertPoints) { + const idx = validKeys.indexOf(p.data_point_key); + if (idx === -1) continue; + + const vals = dataRows.map((row) => row.cells[idx]?.value as number); + const total = vals.length || 1; + const count = vals.filter((v) => checkMatch(v, p)).length; + const pct = count / total; + + const sw = 8; + const sh = 8; + doc + .save() + .rect(x, cy + 3, sw, sh) + .fill(p.hex_color || '#999') + .restore(); + doc.text(`${p.name ?? p.data_point_key}: ${count} (${pf0.format(pct)})`, x + sw + 6, cy, { + width: width - sw - 6 + }); + cy = Math.max(cy, doc.y); + } + + bottomY = cy; + + // IMPORTANT: do not leave global cursor moved + doc.x = savedX; + doc.y = savedY; + + return bottomY; +} diff --git a/src/routes/api/devices/[devEui]/pdf/drawSummaryPanel.ts b/src/routes/api/devices/[devEui]/pdf/drawSummaryPanel.ts new file mode 100644 index 00000000..87663124 --- /dev/null +++ b/src/routes/api/devices/[devEui]/pdf/drawSummaryPanel.ts @@ -0,0 +1,121 @@ +// src/lib/pdf/drawSummaryPanel.ts +import type PDFDocument from 'pdfkit'; +import type { TableRow } from '$lib/pdf'; + +export type ClassificationBand = { label: string; min: number; max: number }; + +export type DrawSummaryPanelOpts = { + dataRows: TableRow[]; + validKeys: string[]; + primaryKey: string; + caption?: string; + samplingLabel: string; + dateRangeLabel: string; + maxLabel: string; + minLabel: string; + avgLabel: string; + stddevLabel: string; + displayStartLabel: string; + displayEndLabel: string; + bands?: ClassificationBand[]; + locale?: string; + fractionDigits?: number; + tableWidthPercent?: number; + lineColor?: string; + rowHeight?: number; +}; + +/** + * Draws the summary panel on the left side of the PDF. + * Returns the topY, firstRowY (y of the first row text), and bottomY. + */ +export function drawSummaryPanel( + doc: PDFDocument, + opts: DrawSummaryPanelOpts +): { topY: number; firstRowY: number; bottomY: number } { + const { + dataRows, + validKeys, + primaryKey, + caption, + samplingLabel, + dateRangeLabel, + maxLabel, + minLabel, + avgLabel, + stddevLabel, + displayStartLabel, + displayEndLabel, + locale = 'ja', + fractionDigits = 2, + tableWidthPercent = 0.5, + lineColor = '#000', + rowHeight = 16 + } = opts; + + if (!dataRows?.length || !validKeys?.length) { + const y0 = doc.y; + if (caption) doc.fontSize(12).text(caption); + doc.fontSize(9).fillColor('#555').text('No data'); + doc.fillColor('black'); + return { topY: y0, firstRowY: y0, bottomY: doc.y }; + } + + // pick index of primary key + const keyIndex = Math.max(0, validKeys.indexOf(primaryKey)); + const values: number[] = dataRows + .map((r) => r.cells?.[keyIndex]?.value as number) + .filter((v) => typeof v === 'number' && !Number.isNaN(v)); + + const total = values.length; + const min = values.length ? Math.min(...values) : NaN; + const max = values.length ? Math.max(...values) : NaN; + const avg = values.length ? values.reduce((a, b) => a + b, 0) / values.length : NaN; + const stddev = + values.length > 1 + ? Math.sqrt(values.reduce((acc, v) => acc + Math.pow(v - avg, 2), 0) / values.length) + : 0; + + const nf = new Intl.NumberFormat(locale, { maximumFractionDigits: fractionDigits }); + const pf = new Intl.NumberFormat(locale, { + style: 'percent', + maximumFractionDigits: fractionDigits + }); + + const lines: string[] = []; + lines.push(`${samplingLabel}: ${total}`); + lines.push(`${dateRangeLabel}: ${displayStartLabel} - ${displayEndLabel}`); + lines.push(`${maxLabel}: ${isFinite(max) ? nf.format(max) : '-'}`); + lines.push(`${minLabel}: ${isFinite(min) ? nf.format(min) : '-'}`); + lines.push(`${avgLabel}: ${isFinite(avg) ? nf.format(avg) : '-'}`); + lines.push(`${stddevLabel}: ${isFinite(stddev) ? nf.format(stddev) : '-'}`); + + const left = doc.page.margins.left; + const availableWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + const tableWidth = availableWidth * tableWidthPercent; + + if (caption) { + doc.fontSize(12).text(caption, left, doc.y, { width: tableWidth }); + doc.moveDown(0.3); + } + + const topY = doc.y; // start of the block + const firstRowY = topY + 3; // actual y we draw the first line at + + let y = topY; + doc.fontSize(10).fillColor('black'); + + for (let i = 0; i < lines.length; i++) { + doc.text(lines[i], left + 5, y + 3, { width: tableWidth - 10, ellipsis: true }); + doc + .moveTo(left, y + rowHeight) + .lineTo(left + tableWidth, y + rowHeight) + .lineWidth(1) + .strokeColor(lineColor) + .stroke(); + y += rowHeight; + } + + doc.y = y + 6; + return { topY, firstRowY, bottomY: doc.y }; +} From d3b00a65f68d3434e0fe162c96d2aa5ef3b8612f Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Tue, 16 Sep 2025 00:24:53 +0900 Subject: [PATCH 14/18] small cleanup --- .../devices/[devEui]/pdf/drawRightAlertPanel.ts | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/routes/api/devices/[devEui]/pdf/drawRightAlertPanel.ts b/src/routes/api/devices/[devEui]/pdf/drawRightAlertPanel.ts index a9258055..b426ddc9 100644 --- a/src/routes/api/devices/[devEui]/pdf/drawRightAlertPanel.ts +++ b/src/routes/api/devices/[devEui]/pdf/drawRightAlertPanel.ts @@ -19,27 +19,12 @@ export function drawRightAlertPanel(opts: { validKeys: string[]; dataRows: TableRow[]; }): number { - const { - doc, - x, - y, - width, - locale, - startLabel, - endLabel, - timezone, - samplingLabel, - sampleCount, - alertPoints, - validKeys, - dataRows - } = opts; + const { doc, x, y, width, locale, alertPoints, validKeys, dataRows } = opts; const savedX = doc.x; const savedY = doc.y; const pf0 = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 }); - const nf0 = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }); doc.fontSize(8); From 38664e24a7bd1eb130cf5b0a3258e7a1236bf42e Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Tue, 16 Sep 2025 00:45:12 +0900 Subject: [PATCH 15/18] adding unit test --- .../api/devices/[devEui]/pdf/server.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/routes/api/devices/[devEui]/pdf/server.test.ts diff --git a/src/routes/api/devices/[devEui]/pdf/server.test.ts b/src/routes/api/devices/[devEui]/pdf/server.test.ts new file mode 100644 index 00000000..0c9d3ec6 --- /dev/null +++ b/src/routes/api/devices/[devEui]/pdf/server.test.ts @@ -0,0 +1,56 @@ +import { GET } from './+server'; +import { jest } from '@jest/globals'; +import { createPDFDataTable } from '$lib/pdf/pdfDataTable'; +import { addFooterPageNumber } from '$lib/pdf/pdfFooterPageNumber'; +import { createPDFLineChartImage } from '$lib/pdf/pdfLineChartImage'; + +jest.mock('$lib/pdf/pdfDataTable'); +jest.mock('$lib/pdf/pdfFooterPageNumber'); +jest.mock('$lib/pdf/pdfLineChartImage'); + +describe('GET /api/devices/[devEui]/pdf', () => { + it('should return a PDF response with correct headers', async () => { + const mockSupabase = { + auth: { + getUser: jest.fn().mockResolvedValue({ + data: { user: { id: 'user-id' } }, + error: null + }) + }, + from: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + eq: jest + .fn() + .mockReturnValue({ + single: jest + .fn() + .mockResolvedValue({ data: { full_name: 'Test User', employer: 'Test Company' } }) + }) + }) + }) + }; + + const params = { devEui: 'test-devEui' }; + const url = new URL( + 'http://localhost/api/devices/test-devEui/pdf?start=2025-05-01&end=2025-06-06&timezone=Asia/Tokyo&locale=ja' + ); + const locals = { supabase: mockSupabase }; + + const response = await GET({ params, url, locals }); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('application/pdf'); + expect(response.headers.get('Content-Disposition')).toContain('device-test-devEui-report.pdf'); + }); + + it('should return 401 if supabase is not provided', async () => { + const params = { devEui: 'test-devEui' }; + const url = new URL('http://localhost/api/devices/test-devEui/pdf'); + const locals = { supabase: null }; + + const response = await GET({ params, url, locals }); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Unauthorized access' }); + }); +}); From 1bfb44e02e88e7a9cd27734a12c1f746ca0b46a5 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 17 Sep 2025 14:20:52 +0900 Subject: [PATCH 16/18] safety --- .../[devEui]/settings/reports/create/+page.svelte | 10 +++++++++- static/build-info.json | 8 ++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.svelte index 514152ad..f39505ea 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.svelte +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.svelte @@ -399,6 +399,7 @@ type="color" style="width: 60px; height: 48px; border-radius: 55px;" bind:value={point.hex_color} + disabled={point.operator === null} /> Alert Point {i + 1} @@ -456,7 +457,14 @@ }} class="w-full" > - + diff --git a/static/build-info.json b/static/build-info.json index 5d37669b..d4d7258f 100644 --- a/static/build-info.json +++ b/static/build-info.json @@ -1,9 +1,9 @@ { - "commit": "328aa4e", - "branch": "develop", + "commit": "38664e2", + "branch": "style-fixes", "author": "Kevin Cantrell", - "date": "2025-08-24T08:49:52.201Z", + "date": "2025-09-16T08:35:24.572Z", "builder": "kevin@kevin-desktop", "ipAddress": "192.168.1.100", - "timestamp": 1756025392202 + "timestamp": 1758011724573 } From d3bfcf28d326f7890917aac3b095366b36bc9b53 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 17 Sep 2025 18:29:12 +0900 Subject: [PATCH 17/18] working --- src/lib/components/CopyButton.svelte | 131 ++++++++++++++++++ .../settings/reports/create/+page.svelte | 113 ++++++++++----- 2 files changed, 206 insertions(+), 38 deletions(-) create mode 100644 src/lib/components/CopyButton.svelte diff --git a/src/lib/components/CopyButton.svelte b/src/lib/components/CopyButton.svelte new file mode 100644 index 00000000..1e01f1e2 --- /dev/null +++ b/src/lib/components/CopyButton.svelte @@ -0,0 +1,131 @@ + + + + + + diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.svelte index f39505ea..4069d536 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.svelte +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.svelte @@ -12,6 +12,7 @@ import type { ActionResult } from '@sveltejs/kit'; import { untrack } from 'svelte'; import { _ } from 'svelte-i18n'; + import CopyButton from '$lib/components/CopyButton.svelte'; let { data, form } = $props(); @@ -34,7 +35,7 @@ Array<{ id: string; name: string; - operator: '=' | '>' | '<' | 'range'; + operator: '=' | '>' | '<' | 'range' | 'null'; // Allow both value?: number; min?: number; max?: number; @@ -71,7 +72,6 @@ } if (isEditing && data.alertPoints) { - debugger; untrack(() => { alertPoints.splice( 0, @@ -80,9 +80,12 @@ id: point.id?.toString() || crypto.randomUUID(), name: point.name || '', operator: - point.operator === 'range' - ? 'range' - : point.operator || ('=' as '=' | '>' | '<' | 'range'), + // Normalize null values to 'null' string + point.operator === null || point.operator === 'null' + ? 'null' + : point.operator === 'range' + ? 'range' + : point.operator || ('=' as '=' | '>' | '<' | 'range'), data_point_key: point.data_point_key || '', min: point.min ?? undefined, max: point.max ?? undefined, @@ -197,7 +200,13 @@ const ranges: Array<{ start: number; end: number; name: string }> = []; alertPoints.forEach((point) => { - // Ensure values are numbers + debugger; + // Exclude Display Only points from validation - check for both 'null' string and null value + if (point.operator === 'null' || point.operator === null || !point.operator) { + console.log(`Skipping validation for Display Only point: ${point.name}`); + return; + } + const value = Number(point.value); const min = Number(point.min); const max = Number(point.max); @@ -233,7 +242,7 @@ } } - ranges.push({ start, end, name: point.name }); + // ranges.push({ start, end, name: point.name }); }); } @@ -251,7 +260,19 @@ const numberLinePoints = $derived( alertPoints .filter((point) => { - // Filter out points with invalid values + // Exclude Display Only points + if (point.operator === 'null' || point.operator === null || !point.operator) { + return false; + } + // Only allow valid operators for NumberLine + return ( + point.operator === '=' || + point.operator === '>' || + point.operator === '<' || + point.operator === 'range' + ); + }) + .filter((point) => { if (point.operator === '=') { return !isNaN(Number(point.value)); } else if (point.operator === 'range') { @@ -262,11 +283,14 @@ return false; }) .map((point) => ({ - ...point, + id: point.id, + name: point.name, + operator: point.operator as '=' | '>' | '<' | 'range', value: point.value != null ? Number(point.value) : undefined, min: point.min != null ? Number(point.min) : undefined, max: point.max != null ? Number(point.max) : undefined, - // Normalize color property for NumberLine component + data_point_key: point.data_point_key, + hex_color: point.hex_color, color: point.hex_color })) ); @@ -395,24 +419,41 @@
    - - - Alert Point {i + 1} - +
    +
    +
    + +
    + + +
    +
    + + Alert Point {i + 1} + +
    +