From 751bbfa8699c0e7638a4a964cdc29f19e2f63aa4 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 6 Nov 2025 15:45:18 +0900 Subject: [PATCH 01/12] polish: improve dashboard contrast and professional appearance - Enhanced text contrast for device names and labels (gray-800/200 for light/dark) - Improved unit notation contrast with font-semibold for better readability - Updated card styling with refined borders and shadows - Added gradient header to location cards for professional polish - Improved icon contrast (gray-700/300 for light/dark modes) - Enhanced border colors for better definition (gray-200/700) - Updated color tokens for better contrast ratios - Refined device panel shadows for subtle, professional appearance - Maintained all data positioning without moving content --- src/app.css | 195 ++++++++++++++---- .../UI/dashboard/DashboardCard.svelte | 66 +++--- .../UI/dashboard/DataRowItem.svelte | 16 +- src/routes/app/dashboard/+page.svelte | 6 +- 4 files changed, 196 insertions(+), 87 deletions(-) diff --git a/src/app.css b/src/app.css index a4288722..f6e221d1 100644 --- a/src/app.css +++ b/src/app.css @@ -16,11 +16,11 @@ --color-primary-hover: #3a8c3f; --color-background: #eaeaea; --color-foreground: #938aee; - --color-foreground-light: #f0f0f0; + --color-foreground-light: #f5f5f5; --color-foreground-dark: #2a2a2a; --color-card: #ffffff; - --color-text: #333333; - --color-text-secondary: rgba(51, 51, 51, 0.7); + --color-text: #1f2937; + --color-text-secondary: rgba(31, 41, 55, 0.7); /* Specialized colors */ --color-air-bg: #e6f7ff; @@ -50,11 +50,15 @@ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); /* Standard UI colors */ - --color-border: rgb(229 231 235); - --color-border-subtle: rgb(229 231 235 / 0.3); + --color-border: rgb(219 224 231); + --color-border-subtle: rgb(229 231 235); --color-surface: rgb(249 250 251); --color-surface-raised: rgb(255 255 255); - --color-text-muted: rgb(107 114 128); + --color-text-muted: rgb(75 85 99); + --color-surface-muted: rgba(255, 255, 255, 0.85); + --color-surface-emphasis: color-mix(in srgb, var(--color-foreground) 6%, #ffffff 94%); + --color-header: color-mix(in srgb, var(--color-background) 80%, rgba(255, 255, 255, 0.8) 20%); + --color-sidebar: color-mix(in srgb, var(--color-background) 75%, rgba(255, 255, 255, 0.9) 25%); } /* Dark mode color variables (activated by .dark class on ) */ @@ -92,6 +96,10 @@ --color-surface: rgb(55 65 81); --color-surface-raised: rgb(55 65 81); --color-text-muted: rgb(156 163 175); + --color-surface-muted: rgba(31, 41, 55, 0.78); + --color-surface-emphasis: color-mix(in srgb, var(--color-foreground-light) 60%, rgba(15, 23, 42, 0.6) 40%); + --color-header: color-mix(in srgb, var(--color-background) 88%, rgba(15, 23, 42, 0.9) 12%); + --color-sidebar: color-mix(in srgb, var(--color-background) 82%, rgba(30, 41, 59, 0.92) 18%); } html, @@ -109,6 +117,15 @@ body { flex-grow: 1; } +body { + background: radial-gradient(circle at top, color-mix(in srgb, var(--color-background) 92%, #ffffff 8%) 0%, var(--color-background) 45%, var(--color-background) 100%); + color: var(--color-text); +} + +.dark body { + background: radial-gradient(circle at top, color-mix(in srgb, var(--color-background) 85%, #0f172a 15%) 0%, var(--color-background) 45%, var(--color-background) 100%); +} + /* Global card styles */ .card { background-color: var(--color-card); @@ -160,20 +177,70 @@ button.primary:hover { background-color: var(--color-primary-hover); } .btn-base { + display: inline-flex; + align-items: center; + justify-content: center; border-radius: var(--btn-radius); font-weight: var(--btn-font-weight); padding: var(--btn-padding-y) var(--btn-padding-x); box-shadow: var(--btn-shadow); - transition: background-color .2s, color .2s, border-color .2s; + transition: + background-color 180ms ease, + color 180ms ease, + border-color 180ms ease, + box-shadow 180ms ease, + transform 120ms ease; outline: none; - line-height: 1.25rem; + line-height: 1.3; + border: 1px solid transparent; +} +.btn-base:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} +.btn-base:active { + transform: translateY(1px); +} +.btn-base:disabled { + opacity: 0.6; + cursor: not-allowed; + box-shadow: none; +} +.btn-primary { + background: var(--btn-primary-bg); + color: var(--btn-primary-text); +} +.btn-primary:hover { + background: var(--btn-primary-bg-hover); +} +.btn-secondary { + background: var(--btn-secondary-bg); + color: var(--btn-secondary-text); +} +.btn-secondary:hover { + background: var(--btn-secondary-bg-hover); +} +.btn-ghost { + background: transparent; + color: inherit; +} +.btn-ghost:hover { + background-color: rgba(0 0 0 / 0.05); +} +.dark .btn-ghost:hover { + background-color: rgba(255 255 255 / 0.08); +} +.btn-outline { + background: transparent; + border: 1px solid var(--btn-secondary-bg-hover); + color: var(--btn-secondary-text); +} +.btn-outline:hover { + background-color: rgba(0 0 0 / 0.05); +} +.dark .btn-outline:hover { + background-color: rgba(255 255 255 / 0.08); } -.btn-primary { background: var(--btn-primary-bg); color: var(--btn-primary-text); } -.btn-primary:hover { background: var(--btn-primary-bg-hover); } -.btn-secondary { background: var(--btn-secondary-bg); color: var(--btn-secondary-text); } -.btn-secondary:hover { background: var(--btn-secondary-bg-hover); } -.btn-ghost { background: transparent; } -.btn-outline { background: transparent; border:1px solid var(--btn-secondary-bg-hover); } /* Sensor data specific styles */ @@ -192,6 +259,65 @@ button.primary:hover { background-color: var(--color-primary-hover); } color: var(--color-text-secondary); } +.surface-card { + background-color: color-mix(in srgb, var(--color-card) 96%, #ffffff 4%); + color: var(--color-text); + border-radius: 0.9rem; + border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent 30%); + box-shadow: + 0 26px 38px -32px rgba(15 23 42 / 0.55), + 0 12px 28px -36px rgba(15 23 42 / 0.45), + 0 1px 3px rgba(15 23 42 / 0.12); + transition: + background-color 180ms ease, + border-color 180ms ease, + box-shadow 200ms ease; +} + +.surface-section { + background-color: color-mix(in srgb, var(--color-surface) 96%, #ffffff 4%); + border-radius: 0.9rem; + border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent 30%); +} + +.surface-muted { + background-color: color-mix(in srgb, var(--color-surface-emphasis) 95%, #ffffff 5%); + border-radius: 0.9rem; +} + +.header-surface { + background-color: var(--color-header); + backdrop-filter: blur(14px); + border-bottom: 1px solid var(--color-border-subtle); + box-shadow: 0 8px 24px -20px rgba(15, 23, 42, 0.4); +} + +.sidebar-surface { + background-color: var(--color-sidebar); + backdrop-filter: blur(18px); + border-right: 1px solid var(--color-border-subtle); + box-shadow: 12px 0 28px -28px rgba(15, 23, 42, 0.45); +} + +.dark .surface-card { + background-color: color-mix(in srgb, var(--color-foreground-dark) 92%, rgba(15, 23, 42, 0.85) 8%); + border-color: color-mix(in srgb, var(--color-border) 65%, transparent 35%); +} + +.dark .surface-section { + background-color: color-mix(in srgb, var(--color-foreground-dark) 88%, rgba(15, 23, 42, 0.8) 12%); + border-color: color-mix(in srgb, var(--color-border) 65%, transparent 35%); +} + +.dark .surface-muted { + background-color: color-mix(in srgb, var(--color-foreground-dark) 75%, rgba(30, 41, 59, 0.9) 25%); +} + +.dark .header-surface, +.dark .sidebar-surface { + border-color: var(--color-border-subtle); +} + .error-message { color: var(--color-error); background-color: var(--color-error-bg); @@ -316,40 +442,23 @@ a { .form-container { display: flex; flex-direction: column; - gap: 1rem; + gap: 1.25rem; background-color: var(--color-card); - padding: 1.5rem; - border-radius: 0.5rem; + padding: 1.75rem; + border-radius: 0.75rem; + border: 1px solid var(--color-border-subtle); + box-shadow: var(--shadow-md); + transition: box-shadow 180ms ease; +} +.form-container:hover { box-shadow: - 0 4px 6px -1px rgb(0 0 0 / 0.1), - 0 2px 4px -2px rgb(0 0 0 / 0.1); + 0 14px 30px -20px rgb(15 23 42 / 0.35), + 0 8px 18px -12px rgb(15 23 42 / 0.2); } .form-container label { font-size: 0.875rem; font-weight: 500; -} -.form-container input, -.form-container textarea, -.form-container select { - border-radius: 0.25rem; - border-width: 1px; - border-color: rgb(209 213 219); - padding: 0.5rem; -} -.dark .form-container input, -.dark .form-container textarea, -.dark .form-container select { - border-color: rgb(55 65 81); -} -.form-container button[type='submit'], -.form-container input[type='submit'] { - color: white; - padding: 0.5rem 1rem; - border-radius: 0.25rem; -} -.form-container button[type='submit']:disabled, -.form-container input[type='submit']:disabled { - opacity: 0.5; + color: var(--color-text); } @@ -669,4 +778,4 @@ input[type="week"]:focus { line-height: 1rem; font-weight: 500; color: var(--color-text-muted); -} \ No newline at end of file +} diff --git a/src/lib/components/UI/dashboard/DashboardCard.svelte b/src/lib/components/UI/dashboard/DashboardCard.svelte index 28b91033..2bbaae6f 100644 --- a/src/lib/components/UI/dashboard/DashboardCard.svelte +++ b/src/lib/components/UI/dashboard/DashboardCard.svelte @@ -24,45 +24,47 @@ }>(); -
-
-

-
+
+
+ {#if loading} - + {:else if allActive} - + {:else if activeDevices.length > 0 && !allInactive} - + {:else} - + {/if} -
- {location.name} - -

+ +

+ {location.name} +

+
+
-
-
- {#if content} - {@render content()} - {/if} -
+
+ {#if content} + {@render content()} + {/if}
diff --git a/src/lib/components/UI/dashboard/DataRowItem.svelte b/src/lib/components/UI/dashboard/DataRowItem.svelte index 9258f06d..e8bdc1f6 100644 --- a/src/lib/components/UI/dashboard/DataRowItem.svelte +++ b/src/lib/components/UI/dashboard/DataRowItem.svelte @@ -144,14 +144,14 @@
- {device.name || `Device ${device.dev_eui}`}
{#if device.latestData}
- {nameToEmoji(primaryDataKey)}
@@ -160,7 +160,7 @@ > {formatNumber({ key: primaryDataKey, value: primaryValue })} {primaryNotation} @@ -169,7 +169,7 @@ {#if secondaryDataKey}
- {nameToEmoji(secondaryDataKey)}
@@ -177,7 +177,7 @@ class="flex flex-nowrap items-baseline text-lg leading-tight font-bold text-gray-900 dark:text-white" > {formatNumber({ key: secondaryDataKey, value: secondaryValue })} - {secondaryNotation} @@ -191,7 +191,7 @@
-

+

{$_('Details')}

From e881dc046fc9bffd40e77be24e12111bc9e4c10d Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 6 Nov 2025 15:48:54 +0900 Subject: [PATCH 03/12] =?UTF-8?q?improve=20=E8=A9=B3=E7=B4=B0=20label=20co?= =?UTF-8?q?ntrast=20-=20use=20amber-700=20for=20better=20light=20mode=20vi?= =?UTF-8?q?sibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed from yellow-700 to amber-700 (darker, more saturated orange-brown) - Changed dark mode from yellow-300 to amber-300 for consistency - Amber-700 has significantly better contrast on white backgrounds (WCAG AA compliant) --- src/lib/components/UI/dashboard/DeviceDataList.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/UI/dashboard/DeviceDataList.svelte b/src/lib/components/UI/dashboard/DeviceDataList.svelte index 4917525d..614fc757 100644 --- a/src/lib/components/UI/dashboard/DeviceDataList.svelte +++ b/src/lib/components/UI/dashboard/DeviceDataList.svelte @@ -56,7 +56,7 @@
-

+

{$_('Details')}

From 30257204b25235276ac19647cc72b7a18858945c Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 6 Nov 2025 16:04:01 +0900 Subject: [PATCH 04/12] fix: keep html/body dark class in sync with theme - when applying theme, toggle `dark` class on both and - add body data-theme attribute for debugging This prevents orphaned `dark` classes on the body element from forcing dark styles (like `dark:` variants) while the user is in light mode. --- src/lib/stores/theme.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/lib/stores/theme.ts b/src/lib/stores/theme.ts index 1bfa24af..52736228 100644 --- a/src/lib/stores/theme.ts +++ b/src/lib/stores/theme.ts @@ -41,15 +41,22 @@ export const themeStore: Writable = writable(initial); function applyDOMTheme(theme: 'light' | 'dark', mode: ThemeMode, system: 'light' | 'dark') { if (typeof document === 'undefined') return; const root = document.documentElement; + const body = document.body; - // Apply/remove Tailwind dark class - if (theme === 'dark') root.classList.add('dark'); - else root.classList.remove('dark'); + // Apply/remove Tailwind dark class consistently on both html and body + const targets = [root, body].filter((el): el is HTMLElement => Boolean(el)); + targets.forEach((el) => { + if (theme === 'dark') el.classList.add('dark'); + else el.classList.remove('dark'); + }); // Data attributes for downstream CSS hooks / debugging root.dataset.theme = theme; root.dataset.mode = mode; // user selected value (light|dark|system) root.dataset.system = system; // current system preference + if (body) { + body.dataset.theme = theme; + } const explicit = mode !== 'system'; if (explicit) root.dataset.explicit = 'true'; else delete root.dataset.explicit; @@ -110,6 +117,9 @@ export function initThemeOnce() { const mq = window.matchMedia('(prefers-color-scheme: dark)'); mq.addEventListener('change', (e) => { themeStore.update((s) => { + if (s.mode !== 'system') { + return s; + } const system = e.matches ? 'dark' : 'light'; const effective = deriveEffective(s.mode, system); applyDOMTheme(effective, s.mode, system); From dbc40e61bf152d83c7f9b0649d6c4da59639d80d Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 6 Nov 2025 16:10:29 +0900 Subject: [PATCH 05/12] =?UTF-8?q?fix:=20make=20=E8=A9=B3=E7=B4=B0=20label?= =?UTF-8?q?=20high-contrast=20in=20both=20themes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - use text-gray-900 in light mode and text-gray-100 in dark mode for the Details heading - ensures the label is readable regardless of theme selection --- src/lib/components/UI/dashboard/DeviceDataList.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/UI/dashboard/DeviceDataList.svelte b/src/lib/components/UI/dashboard/DeviceDataList.svelte index 614fc757..e5364cef 100644 --- a/src/lib/components/UI/dashboard/DeviceDataList.svelte +++ b/src/lib/components/UI/dashboard/DeviceDataList.svelte @@ -56,7 +56,7 @@
-

+

{$_('Details')}

From e04e35cf3af7f029b71f87cadcb32e06ce68730c Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 6 Nov 2025 16:34:45 +0900 Subject: [PATCH 06/12] saving massive changes --- src/app.d.ts | 6 +- src/hooks.server.ts | 51 +- src/lib/components/BatteryLevel.svelte | 15 +- src/lib/components/CopyButton.svelte | 11 +- src/lib/components/DataTable.svelte | 70 +- src/lib/components/GlobalSidebar.svelte | 72 +-- src/lib/components/Header.svelte | 87 +-- src/lib/components/RelayControl.svelte | 91 ++- src/lib/components/Reports/NewPoint.svelte | 49 +- .../UI/dashboard/DashboardCard.svelte | 4 +- .../UI/dashboard/LocationsPanel.svelte | 114 ++-- src/lib/components/UI/detail/DateRange.svelte | 121 ++-- src/lib/components/UI/form/Avatar.svelte | 2 +- src/lib/components/UI/form/Select.svelte | 2 +- src/lib/components/UI/form/TextInput.svelte | 2 +- .../UI/form/UserPermissionsSelector.svelte | 1 - .../components/UI/icons/CalendarIcon.svelte | 31 +- .../UI/icons/ChevronLeftIcon.svelte | 26 +- .../UI/icons/ChevronRightIcon.svelte | 25 +- src/lib/components/UI/primitives/Icon.svelte | 9 +- src/lib/components/global/Breadcrumbs.svelte | 11 +- src/lib/components/ui/base/Icon.svelte | 12 +- src/lib/dtos/AirDataDto.ts | 444 ++++++------- src/lib/dtos/DeviceDto.ts | 440 ++++++------- src/lib/dtos/RuleDto.ts | 240 +++---- src/lib/interfaces/ILocationService.ts | 2 +- src/lib/interfaces/IRuleService.ts | 173 ++--- src/lib/interfaces/ISessionService.ts | 16 +- src/lib/models/Device.ts | 1 + src/lib/models/Rule.ts | 122 +--- src/lib/repositories/BaseRepository.ts | 18 +- .../repositories/DeviceOwnersRepository.ts | 12 +- src/lib/repositories/DeviceRepository.ts | 19 +- src/lib/repositories/LocationRepository.ts | 38 +- src/lib/repositories/RuleRepository.ts | 20 +- src/lib/services/DeviceDataService.ts | 41 +- src/lib/services/LocationService.ts | 18 +- src/lib/services/RuleService.ts | 4 +- src/lib/services/SessionService.ts | 71 +- src/lib/tests/NonTrafficIntegration.test.ts | 22 +- src/lib/tests/PDFReportIntegration.test.ts | 1 + src/lib/tests/ReportPDFIntegration.test.ts | 1 + src/lib/tests/TrafficDataIntegration.test.ts | 1 + src/lib/tests/mocks/MockSupabase.ts | 258 ++++---- src/lib/tests/services/DeviceService.test.ts | 281 ++++---- .../tests/services/LocationService.test.ts | 335 +++++----- src/lib/tests/services/RuleService.test.ts | 609 +++++++++--------- src/lib/utilities/ConvertSensorDataObject.ts | 122 ++-- src/lib/utilities/dashboard.ts | 191 +++--- src/routes/+page.server.ts | 13 +- .../[devEui]/pdf/drawRightAlertPanel.ts | 3 +- .../devices/[devEui]/pdf/drawSummaryPanel.ts | 3 +- .../api/devices/[devEui]/pdf/server.test.ts | 13 +- .../locations/[locationId]/devices/+server.ts | 24 +- .../account-settings/general/+page.server.ts | 34 +- .../general/line/callback/+server.ts | 2 +- .../payment/add-subscription/+page.server.ts | 2 +- src/routes/app/dashboard/location/$types.ts | 28 - .../app/dashboard/location/+page.server.ts | 121 ++-- .../devices/[devEui]/reports/pdf/+server.ts | 80 ++- .../devices/[devEui]/settings/+page.server.ts | 50 ++ .../devices/[devEui]/settings/+page.svelte | 76 +-- .../settings/reports/create/+page.server.ts | 35 +- .../[devEui]/settings/rules/+page.server.ts | 7 +- .../devices/create/+page.server.ts | 285 ++++---- src/routes/auth/login/+page.server.ts | 32 +- static/build-info.json | 8 +- 67 files changed, 2637 insertions(+), 2491 deletions(-) delete mode 100644 src/routes/app/dashboard/location/$types.ts diff --git a/src/app.d.ts b/src/app.d.ts index 38b659e5..8167efac 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -4,14 +4,16 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces -import type { Database } from '$lib/types/database.types'; +import type { Database } from '../database.types'; import type { Session, SupabaseClient, User } from '@supabase/supabase-js'; +type TypedSupabaseClient = SupabaseClient; + declare global { namespace App { // interface Error {} interface Locals { - supabase: SupabaseClient; + supabase: TypedSupabaseClient; safeGetSession: () => Promise<{ session: Session | null; user: User | null }>; session: Session | null; user: User | null; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 94790b8d..7b042bee 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -2,6 +2,8 @@ import { error, redirect, type Handle } from '@sveltejs/kit'; import { createServerClient } from '@supabase/ssr'; import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; import { createClient } from '@supabase/supabase-js'; +import type { Session, User } from '@supabase/supabase-js'; +import type { Database } from '../database.types'; const PUBLIC_ROUTES = [ '/offline.html', @@ -54,19 +56,23 @@ const handleCORS: Handle = async ({ event, resolve }) => { // Handle for Supabase authentication and session management const handleSupabase: Handle = async ({ event, resolve }) => { // Create a Supabase client specific for server-side rendering (SSR) - event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { - cookies: { - getAll: () => event.cookies.getAll(), - setAll: (cookiesToSet) => { - // Store cookies to be set later instead of setting them immediately - event.locals.supabaseCookies = cookiesToSet; + event.locals.supabase = createServerClient( + PUBLIC_SUPABASE_URL, + PUBLIC_SUPABASE_ANON_KEY, + { + cookies: { + getAll: () => event.cookies.getAll(), + setAll: (cookiesToSet) => { + // Store cookies to be set later instead of setting them immediately + event.locals.supabaseCookies = cookiesToSet; + } } } - }); + ) as any; // Handle JWT token authentication for API routes - let tokenSession = null; - let tokenUser = null; + let tokenSession: Session | null = null; + let tokenUser: User | null = null; // Get headers from the request for better debugging const headers = new Headers(event.request.headers); @@ -84,17 +90,32 @@ const handleSupabase: Handle = async ({ event, resolve }) => { event.url.pathname.startsWith('/api') || event.url.pathname.includes('/reports/pdf'); if (jwt && isApiOrAppRoute) { - const jwt = authorizationHeader?.replace(/^Bearer\s+/i, '').trim(); - if (!jwt) { + const bearer = authorizationHeader?.replace(/^Bearer\s+/i, '').trim(); + if (!bearer) { throw error(401, 'Unauthorized access: No JWT token provided'); } - const jwtSupabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { + const jwtSupabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { global: { - headers: { Authorization: `Bearer ${jwt}` } + headers: { Authorization: `Bearer ${bearer}` } }, auth: { persistSession: false } }); - event.locals.supabase = jwtSupabase; + + const [{ data: sessionData, error: sessionError }, { data: userData, error: userError }] = + await Promise.all([jwtSupabase.auth.getSession(), jwtSupabase.auth.getUser()]); + + if (sessionError) { + console.error('JWT session lookup error:', sessionError.message); + } + + if (userError || !userData?.user) { + throw error(401, 'Unauthorized access: Invalid JWT token'); + } + + tokenSession = sessionData.session ?? null; + tokenUser = userData.user; + + event.locals.supabase = jwtSupabase as any; return await resolve(event, { filterSerializedResponseHeaders(name) { return name === 'content-range' || name === 'x-supabase-api-version'; @@ -184,6 +205,8 @@ const handleSupabase: Handle = async ({ event, resolve }) => { // Token is valid, set the user from the validated token //console.log('Valid API token for user:', data.user.email); event.locals.user = data.user; + tokenUser = data.user; + tokenSession = null; // Continue processing the request const response = await resolve(event, { diff --git a/src/lib/components/BatteryLevel.svelte b/src/lib/components/BatteryLevel.svelte index 3aba1de1..35bccdf9 100644 --- a/src/lib/components/BatteryLevel.svelte +++ b/src/lib/components/BatteryLevel.svelte @@ -4,16 +4,27 @@ import { Tooltip } from 'bits-ui'; // Props + type IconSize = 'small' | 'medium' | 'large' | 'xlarge'; + let { value = 50, // 0..100 - size = 'medium', + size = 'medium' as IconSize, showLabel = false, // show % text next to icon charging = false, // optional charging bolt overlay lowThreshold = 15, // % -> red midThreshold = 40, // % -> amber highThreshold = 70, // % -> yellow; above is green ariaLabel = 'Battery level' - } = $props(); + } = $props<{ + value?: number; + size?: IconSize; + showLabel?: boolean; + charging?: boolean; + lowThreshold?: number; + midThreshold?: number; + highThreshold?: number; + ariaLabel?: string; + }>(); function getBatteryColor(): string { if (value <= lowThreshold) return 'red'; diff --git a/src/lib/components/CopyButton.svelte b/src/lib/components/CopyButton.svelte index 1e01f1e2..9ad15107 100644 --- a/src/lib/components/CopyButton.svelte +++ b/src/lib/components/CopyButton.svelte @@ -1,6 +1,7 @@ -
-
-
+
+
+
-
-

{$_('Sensor Data - Today')}

+
+

{$_('Sensor Data - Today')}

{#if filteredData.length === 0} -
-

{$_('No data available for today')}

+
+

{$_('No data available for today')}

{:else} {#each visibleColumns as column} @@ -88,15 +98,11 @@ - + {#each filteredData as row, rowIndex} - + {#each visibleColumns as column} - {/each} diff --git a/src/lib/components/GlobalSidebar.svelte b/src/lib/components/GlobalSidebar.svelte index 70ae1b74..5fd4c2bd 100644 --- a/src/lib/components/GlobalSidebar.svelte +++ b/src/lib/components/GlobalSidebar.svelte @@ -140,33 +140,21 @@
{$_(column)}
+ {formatCellValue(column, row[column])} - {report.cw_device?.name ?? $_('N/A')} ({report.dev_eui ?? $_('N/A')}) + + {report.cw_device?.name ?? $_('N/A')} + ({report.dev_eui ?? $_('N/A')}) + {formatDate(report.created_at)} From 049602a16f1eaed363aabda3818379f19b742a35 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 12 Nov 2025 12:53:43 +0900 Subject: [PATCH 11/12] pushing tons of updates --- src/lib/components/RelayControl.svelte | 2 +- src/lib/pdf/pdfDataTable.ts | 53 ++++++++++++++++++- .../api/devices/[devEui]/pdf/+server.ts | 19 +++++-- .../settings/reports/create/+page.svelte | 7 +-- 4 files changed, 70 insertions(+), 11 deletions(-) diff --git a/src/lib/components/RelayControl.svelte b/src/lib/components/RelayControl.svelte index 64f40375..eed88eae 100644 --- a/src/lib/components/RelayControl.svelte +++ b/src/lib/components/RelayControl.svelte @@ -31,7 +31,7 @@ ]; const POLL_INTERVAL_MS = 10_000; - const COOLDOWN_SECONDS = 15; + const COOLDOWN_SECONDS = 120; let busy: Record = $state({ relay1: false, relay2: false }); let relayState: Record = $state({ relay1: false, relay2: false }); diff --git a/src/lib/pdf/pdfDataTable.ts b/src/lib/pdf/pdfDataTable.ts index 9c2d0a40..d34f52c3 100644 --- a/src/lib/pdf/pdfDataTable.ts +++ b/src/lib/pdf/pdfDataTable.ts @@ -32,7 +32,7 @@ const DEFAULT_CONFIG: TableConfig = { const MIN_COLUMN_SCALE = 0.9; -const isAlertRow = (row: TableRow) => +export const rowHasAlert = (row: TableRow) => [row.header, ...row.cells].some((cell) => cell.bgColor && cell.bgColor !== '#ffffff'); export function sampleDataRowsForTable( @@ -41,7 +41,56 @@ export function sampleDataRowsForTable( ): TableRow[] { const samplingInterval = Math.max(1, takeEvery || 1); if (samplingInterval <= 1) return dataRows; - return dataRows.filter((row, idx) => idx % samplingInterval === 0 || isAlertRow(row)); + return dataRows.filter((row, idx) => idx % samplingInterval === 0 || rowHasAlert(row)); +} + +function getRowTimestamp(row: TableRow): number | null { + const value = row?.header?.value; + if (value instanceof Date) return value.getTime(); + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.length) { + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + if (row?.header?.label) { + const parsed = Date.parse(row.header.label); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + return null; +} + +export function sampleDataRowsByInterval( + dataRows: TableRow[], + intervalMinutes: number +): TableRow[] { + const intervalMs = intervalMinutes > 0 ? intervalMinutes * 60 * 1000 : 0; + if (!intervalMs) return dataRows; + const sampled: TableRow[] = []; + let lastKeptTimestamp: number | null = null; + + for (const row of dataRows) { + const timestamp = getRowTimestamp(row); + const hasAlert = rowHasAlert(row); + const shouldInclude = + hasAlert || + !sampled.length || + timestamp === null || + lastKeptTimestamp === null || + timestamp - lastKeptTimestamp >= intervalMs; + + if (shouldInclude) { + sampled.push(row); + if (timestamp !== null) { + lastKeptTimestamp = timestamp; + } + } + } + + return sampled; } /** diff --git a/src/routes/api/devices/[devEui]/pdf/+server.ts b/src/routes/api/devices/[devEui]/pdf/+server.ts index 44832766..afe19ee4 100644 --- a/src/routes/api/devices/[devEui]/pdf/+server.ts +++ b/src/routes/api/devices/[devEui]/pdf/+server.ts @@ -3,7 +3,11 @@ import { i18n } from '$lib/i18n/index.svelte'; import type { DeviceDataRecord } from '$lib/models/DeviceDataRecord'; import type { ReportAlertPoint } from '$lib/models/Report'; import type { TableCell, TableRow } from '$lib/pdf'; -import { createPDFDataTable, sampleDataRowsForTable } from '$lib/pdf/pdfDataTable'; +import { + createPDFDataTable, + sampleDataRowsForTable, + sampleDataRowsByInterval +} from '$lib/pdf/pdfDataTable'; import { addFooterPageNumber } from '$lib/pdf/pdfFooterPageNumber'; import { createPDFLineChartImage } from '$lib/pdf/pdfLineChartImage'; import { checkMatch, getValue } from '$lib/pdf/utils'; @@ -256,7 +260,10 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) const contentWidth = doc.page.width - marginLeft - marginRight; // Title - const isWeekly = Math.abs(userEnd.diff(userStart, 'days').days - 7) < 0.1; + const totalRangeDays = Math.abs(userEnd.diff(userStart, 'days').days); + const isWeekly = Math.abs(totalRangeDays - 7) < 0.1; + const chartIntervalMinutes = totalRangeDays > 7 ? 60 : totalRangeDays > 3 ? 30 : 0; + const tableDisplayIntervalMinutes = 30; // const titleText = isWeekly ? $_('device_report_weekly') : $_('device_report_monthly'); // doc.fontSize(16).text(`${titleText} ${$_('device_report')}`); doc.fontSize(16).text(`${$_('device_report')}`); @@ -386,7 +393,8 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) cells: [...tableKeyColumns, { label: $_('comment'), width: 40 }] }; const dataRowsTable = getDataRows(tableKeys); - const tableConfig = { timezone: timezoneParam }; + const sampledTableRows = sampleDataRowsByInterval(dataRowsTable, tableDisplayIntervalMinutes); + const tableConfig = { timezone: timezoneParam, takeEvery: 1 }; // LEFT summary const primaryKey = tableKeys[0] ?? validKeys[0] ?? 'temperature_c'; @@ -441,7 +449,8 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) } else { doc.moveDown(2); } - const sampledChartRows = sampleDataRowsForTable(getDataRows([key]), tableConfig.takeEvery); + const chartRows = getDataRows([key]); + const sampledChartRows = sampleDataRowsByInterval(chartRows, chartIntervalMinutes); createPDFLineChartImage({ doc, dataHeader: { @@ -462,7 +471,7 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) createPDFDataTable({ doc, dataHeader: dataHeaderTable, - dataRows: dataRowsTable, + dataRows: sampledTableRows, config: tableConfig }); 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 8ec1b4e2..7eb0cd49 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 @@ -72,11 +72,12 @@ }); } - if (previouslyUsedAlertColors) { + if (alertPointsColorHistory) { + debugger; untrack(() => { - previouslyUsedAlertColors.splice( + alertPointsColorHistory.splice( 0, - previouslyUsedAlertColors.length, + alertPointsColorHistory.length, ...data.previouslyUsedAlertColors.data .map((item: any) => item.hex_color) .filter((color: string) => color) From 9b5b6e298ef67db044930ab0061e9d4b33e89844 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 12 Nov 2025 13:18:57 +0900 Subject: [PATCH 12/12] fixed page error --- .../settings/reports/create/+page.svelte | 309 ++++++++---------- 1 file changed, 128 insertions(+), 181 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 7eb0cd49..9f8e3e34 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 @@ -10,145 +10,154 @@ import { success as toastSuccess, error as toastError } from '$lib/stores/toast.svelte'; import type { ReportAlertPoint } from '$lib/models/Report.js'; 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(); - // Extract data properties + type AlertPointState = { + id: string; + name: string; + operator: '=' | '>' | '<' | 'range' | 'null'; + value?: number; + min?: number; + max?: number; + data_point_key?: string; + hex_color: string; + }; + + type RecipientState = { + id: string; + email: string; + name: string; + }; + + type ScheduleState = { + id: string; + frequency: 'daily' | 'weekly' | 'monthly'; + time: string; + days?: number[]; + }; + + const getColorHistory = (source: unknown): string[] => { + const base = Array.isArray(source) ? source : (source as { data?: unknown })?.data; + if (!Array.isArray(base)) return []; + return base + .map((item) => (item as { hex_color?: string })?.hex_color) + .filter((color): color is string => typeof color === 'string' && color.length); + }; + + const normalizeAlertPoints = (source: unknown): AlertPointState[] => { + if (!Array.isArray(source)) return []; + return source.map((point: any) => ({ + id: point.id?.toString() || crypto.randomUUID(), + name: point.name || '', + operator: + 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, + value: point.value ?? undefined, + hex_color: point.hex_color || '#3B82F6' + })); + }; + + const normalizeRecipients = (source: unknown): RecipientState[] => { + if (!Array.isArray(source)) return []; + return source.map((recipient: any) => ({ + id: recipient.id?.toString() || crypto.randomUUID(), + email: recipient.email || '', + name: recipient.name || '' + })); + }; + + const normalizeSchedules = (source: unknown): ScheduleState[] => { + if (!Array.isArray(source)) return []; + return source.map((schedule: any) => ({ + id: schedule.id?.toString() || crypto.randomUUID(), + frequency: schedule.end_of_week ? 'weekly' : schedule.end_of_month ? 'monthly' : 'daily', + time: schedule.time || '09:00', + days: schedule.days || [] + })); + }; + const devEui = $derived(data.devEui); const locationId = $derived(data.locationId); const report = $derived(data.report); const isEditing = $derived(data.isEditing); const recipientsData = $derived(data.recipients); const dataKeys = $derived(data.dataKeys); - const alertPointsColorHistory = $derived(data.previouslyUsedAlertColors); - // Form state - let reportName = $state(''); + const initialColorHistory = getColorHistory(data.previouslyUsedAlertColors); + let alertPointsColorHistory = $state(initialColorHistory); + + const initialReportName = isEditing && report ? report.name || '' : ''; + let reportName = $state(initialReportName); let isSubmitting = $state(false); let showErrors = $state(false); let formEl: HTMLFormElement; - // Alert points state - using $state for deep reactivity - let alertPoints = $state< - Array<{ - id: string; - name: string; - operator: '=' | '>' | '<' | 'range' | 'null'; // Allow both - value?: number; - min?: number; - max?: number; - data_point_key?: string; - hex_color: string; - }> - >([]); - - // Recipients state - let recipients = $state< - Array<{ - id: string; - email: string; - name: string; - }> - >([]); - - // Schedules state - let schedules = $state< - Array<{ - id: string; - frequency: 'daily' | 'weekly' | 'monthly'; - time: string; - days?: number[]; - }> - >([]); - - // Initialize form data from loaded report if editing - $effect(() => { - if (isEditing && report) { - untrack(() => { - reportName = report.name || ''; - }); - } + const initialAlertPoints = isEditing ? normalizeAlertPoints(data.alertPoints) : []; + const initialRecipients = isEditing ? normalizeRecipients(recipientsData) : []; + const initialSchedules = isEditing ? normalizeSchedules(data.schedules) : []; - if (alertPointsColorHistory) { - debugger; - untrack(() => { - alertPointsColorHistory.splice( - 0, - alertPointsColorHistory.length, - ...data.previouslyUsedAlertColors.data - .map((item: any) => item.hex_color) - .filter((color: string) => color) - ); - }); - } + let alertPoints = $state(initialAlertPoints); + let recipients = $state(initialRecipients); + let schedules = $state(initialSchedules); - if (isEditing && data.alertPoints) { - untrack(() => { - alertPoints.splice( - 0, - alertPoints.length, - ...data.alertPoints.map((point: any) => ({ - id: point.id?.toString() || crypto.randomUUID(), - name: point.name || '', - operator: - // 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, - value: point.value ?? undefined, - hex_color: point.hex_color || '#3B82F6' - })) - ); - }); - } + const validationErrors = $derived(() => { + const errors: string[] = []; + const ranges: Array<{ start: number; end: number; name: string }> = []; - if (isEditing && recipientsData) { - untrack(() => { - recipients.splice( - 0, - recipients.length, - ...data.recipients.map((recipient: any) => ({ - id: recipient.id?.toString() || crypto.randomUUID(), - email: recipient.email || '', - name: recipient.name || '' - })) - ); - }); - } + alertPoints.forEach((point) => { + if (point.operator === 'null' || point.operator === null || !point.operator) { + return; + } - if (isEditing && data.schedules) { - untrack(() => { - schedules.splice( - 0, - schedules.length, - ...data.schedules.map((schedule: any) => { - let frequency: 'daily' | 'weekly' | 'monthly' = schedule.end_of_week - ? 'weekly' - : schedule.end_of_month - ? 'monthly' - : 'daily'; - return { - id: schedule.id?.toString() || crypto.randomUUID(), - frequency, - time: schedule.time || '09:00', - days: schedule.days || [] - }; - }) - ); - }); - } - }); + const value = Number(point.value); + const min = Number(point.min); + const max = Number(point.max); + + let start: number; + let end: number; + + if (point.operator === '=') { + if (isNaN(value)) return; + start = end = value; + } else if (point.operator === 'range') { + if (isNaN(min) || isNaN(max)) return; + start = min; + end = max; + } else if (point.operator === '>') { + if (isNaN(value)) return; + start = value; + end = Infinity; + } else if (point.operator === '<') { + if (isNaN(value)) return; + start = -Infinity; + end = value; + } else { + return; + } - // Validation errors - let validationErrors = $state([]); + for (const existingRange of ranges) { + if ( + (start <= existingRange.end && end >= existingRange.start) || + (existingRange.start <= end && existingRange.end >= start) + ) { + errors.push(`"${point.name}" overlaps with "${existingRange.name}"`); + } + } + + ranges.push({ start, end, name: point.name }); + }); + + return errors; + }); // Color palette for alert points const colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899']; @@ -206,69 +215,7 @@ } } - // Validation logic - function validateRanges() { - validationErrors.splice(0, validationErrors.length); - - // Check for overlapping ranges - const ranges: Array<{ start: number; end: number; name: string }> = []; - - alertPoints.forEach((point) => { - 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); - - let start: number, end: number; - - if (point.operator === '=') { - if (isNaN(value)) return; - start = end = value; - } else if (point.operator === 'range') { - if (isNaN(min) || isNaN(max)) return; - start = min; - end = max; - } else if (point.operator === '>') { - if (isNaN(value)) return; - start = value; - end = Infinity; - } else if (point.operator === '<') { - if (isNaN(value)) return; - start = -Infinity; - end = value; - } else { - return; // Skip invalid points - } - - // Check for overlaps with existing ranges - for (const existingRange of ranges) { - if ( - (start <= existingRange.end && end >= existingRange.start) || - (existingRange.start <= end && existingRange.end >= start) - ) { - validationErrors.push(`"${point.name}" overlaps with "${existingRange.name}"`); - } - } - - // ranges.push({ start, end, name: point.name }); - }); - } - - // Watch for changes to alert points - $effect(() => { - // Only track alertPoints changes, not validationErrors changes - const points = alertPoints; - // Use untrack to prevent reactive loops when updating validationErrors - untrack(() => { - validateRanges(); - }); - }); + // Validation errors computed from alert points // Derived number line points for visualization const numberLinePoints = $derived(