From 4e22f1af59798e58276257d53dcefe40eba7f70e Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Tue, 14 Apr 2026 13:37:14 +0900 Subject: [PATCH 1/3] now displaying ALL values from sensors in list --- CLAUDE.md | 426 ++++++++++++++++++ package.json | 2 +- pnpm-lock.yaml | 10 +- src/lib/api/api-error.ts | 51 +++ .../dashboard/DashboardDeviceCards.svelte | 74 ++- .../DashboardDeviceCards.svelte.spec.ts | 1 - .../dashboard/DashboardDeviceTable.svelte | 8 +- .../dashboard/dashboard-device-data.spec.ts | 11 +- .../dashboard/dashboard-device-data.ts | 12 - .../dashboard-device-refresh.spec.ts | 1 - src/lib/components/dashboard/device-cards.ts | 108 ++++- .../components/dashboard/device-table.spec.ts | 3 - src/lib/components/dashboard/device-table.ts | 1 - src/lib/interfaces/device.interface.ts | 1 - src/routes/+page.server.ts | 3 +- src/routes/locations/+page.server.ts | 37 +- src/routes/locations/+page.svelte | 23 +- .../locations/[location_id]/+page.server.ts | 32 +- .../locations/[location_id]/+page.svelte | 37 +- .../devices/[dev_eui]/+layout.server.ts | 5 +- .../devices/[dev_eui]/+page.server.ts | 19 +- .../devices/[dev_eui]/+page.svelte | 62 +-- .../[dev_eui]/settings/+page.server.ts | 44 +- .../[location_id]/devices/create/+page.svelte | 170 ++----- .../[location_id]/settings/+page.svelte | 16 +- src/routes/locations/create/+page.svelte | 81 ++-- src/routes/reports/+page.server.ts | 59 +-- src/routes/reports/+page.svelte | 1 - .../reports/[report_id]/edit/+page.svelte | 4 +- src/routes/rules/+page.server.ts | 68 +-- src/routes/rules/+page.svelte | 1 - src/routes/rules/create/+page.svelte | 8 +- 32 files changed, 862 insertions(+), 517 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/lib/api/api-error.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8811d7b6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,426 @@ +# CropWatch — Code Patterns & Conventions + +This document is the authoritative reference for how pages, forms, and components are built in this codebase. Follow these patterns when adding or modifying code so everything stays consistent and maintainable. + +--- + +## Tech stack + +| Concern | Tool | +|---|---| +| Framework | SvelteKit 2 + Svelte 5 (runes mode) | +| Language | TypeScript | +| Styles | Tailwind CSS 4 + design-token CSS vars | +| UI library | `@cropwatchdevelopment/cwui` (`CwXxx` components) | +| Layout helpers | `$lib/components/layout` (`AppXxx` components) | +| i18n | Paraglide — always `import { m } from '$lib/paraglide/messages.js'` | +| API | `ApiService` from `$lib/api/api.service` | +| Auth | Supabase JWT; token available as `locals.jwtString` server-side | + +--- + +## Page structure + +Every authenticated app page must use `` as its root. Never use a raw `
` container with hard-coded padding instead. + +```svelte + + ...page content... + +``` + +`AppPage` provides consistent max-width clamping, horizontal padding, and a vertical flex column with `gap-4` between direct children. Do not add extra padding or gap on the shell div inside. + +--- + +## Back navigation + +Every page that can be reached by navigating "into" something (create, edit, settings, detail) must show a back button as the **first child** of ``, before any cards or content. + +**Always use:** +```svelte + goto(resolve('/target-route'))}> + ← {m.action_back()} + +``` + +- `variant="ghost"` keeps it visually quiet — it is a secondary action. +- `size="sm"` keeps it compact at the top of the page. +- Use `resolve()` from `$app/paths` for all internal routes so base-path handling is correct. + +--- + +## Form pages + +### Server actions (preferred) + +Use SvelteKit server actions (`export const actions` in `+page.server.ts`) for all create/edit forms where the data can be expressed as flat form fields. This keeps mutation logic on the server and out of the component. + +```svelte + +
{ + submitting = true; + return async ({ result }) => { + submitting = false; + if (result.type === 'success') { + await goto(resolve('/target'), { invalidateAll: true }); + return; + } + await applyAction(result); + if (result.type === 'failure' && typeof result.data?.error === 'string') { + toast.add({ tone: 'danger', message: result.data.error }); + } + }; +}}> +``` + +```ts +// +page.server.ts +export const actions: Actions = { + default: async ({ request, locals, fetch }) => { + const authToken = locals.jwtString ?? null; + if (!authToken) return fail(401, { error: m.auth_not_authenticated() }); + + const formData = await request.formData(); + // validate, call ApiService, return fail() or { success: true } + } +}; +``` + +**On validation failure** — return `fail(400, { error: '...', ...echoedFields })` so the form can re-populate. Always echo field values back so the user does not lose their input. + +### Direct API calls (complex wizard forms) + +When a form has complex dynamic client-side state that cannot be expressed as flat HTML form fields (e.g., multi-step wizards with dynamic criteria lists), submit directly from the component via a `handleSubmit` async function. The rules create/edit pages are the canonical example. + +```ts +async function handleSubmit() { + if (!isFormValid || submitting) return; + submitting = true; + try { + const api = new ApiService({ authToken: data.authToken }); + await api.doSomething(payload); + toast.add({ tone: 'success', message: m.thing_success() }); + goto(resolve('/target')); + } catch { + toast.add({ tone: 'danger', message: m.thing_failed() }); + } finally { + submitting = false; + } +} +``` + +--- + +## Form layout + +Wrap all form fields in `` inside a ``. Use `` for the submit/cancel button pair. + +```svelte + + + + + + {#if form?.error} + +

{form.error}

+
+ {/if} + + + + + +
+ + +
+ + + + goto(resolve('/target'))}> + {m.action_cancel()} + + + {m.action_save()} + + + +
+ +
+``` + +**Rules:** +- `` — always `padded` on the root stack inside a card; the padding comes from the component, not from the card itself. +- `` — always the last item in a form stack; cancel on the left, primary action on the right. +- Cancel button uses `variant="ghost"`, primary action uses `variant="primary"`. +- Never add raw `
` wrappers around fields. + +--- + +## Error display + +Use `` from `$lib/components/layout` — never a raw `

`. + +| Situation | Tone | +|---|---| +| Server action validation failure | `danger` | +| User needs to fix something before proceeding | `warning` | +| Informational (e.g., no devices found) | `neutral` | +| Summary / preview | `info` | + +```svelte + +

{form.error}

+
+``` + +For validation issue lists (multiple items), show them as a `
    ` inside the notice body and set `ariaLive="polite"`. + +--- + +## State management + +### Form field state + +Initialize form fields with `$state`, seeded from the server data on first render: + +```ts +// Create form — blank initial state +let name = $state(form?.name ?? ''); + +// Edit form — seed from the loaded record (capture once to avoid reactive re-seed) +const rule = (() => data.rule)(); +let ruleName = $state(rule.name); +``` + +Never use `$derived` for a mutable form field. `$derived` is read-only; `bind:value` on a derived variable causes unexpected behaviour. + +### Derived / computed values + +Use `$derived` for computed read-only values: + +```ts +let isFormValid = $derived(name.trim().length > 0 && email.trim().length > 0); +let deviceOptions = $derived(data.devices.map(d => ({ label: d.name, value: d.dev_eui }))); +``` + +Use `$derived.by(() => { ... })` when the computation needs intermediate variables. + +### App-wide state + +Access global state (devices, session, profile, etc.) via `getAppContext()` from `$lib/appContext.svelte`: + +```ts +const app = getAppContext(); +// app.devices, app.session, app.accessToken, etc. +``` + +Do not duplicate this data into local state. + +--- + +## API calls + +### Server-side (in `+page.server.ts`) + +```ts +const api = new ApiService({ fetchFn: fetch, authToken }); +const result = await api.getSomething(); +``` + +Always pass `fetchFn: fetch` on the server so SvelteKit's request context is used correctly. + +### Client-side (in `.svelte` components) + +```ts +const api = new ApiService({ authToken: data.authToken }); +const result = await api.getSomething(); +``` + +Do not pass `fetchFn` on the client — the global `fetch` is used automatically. + +### Error handling + +Use `readApiErrorMessage` from `$lib/api/api-error` to turn any caught API payload into a +display string. Never copy-paste this logic inline. + +```ts +import { ApiServiceError } from '$lib/api/api.service'; +import { readApiErrorMessage } from '$lib/api/api-error'; + +try { + await api.doSomething(); +} catch (err) { + const payload = err instanceof ApiServiceError ? err.payload : err; + const status = err instanceof ApiServiceError ? err.status : 500; + return fail(status, { error: readApiErrorMessage(payload, m.generic_error()) }); +} +``` + +--- + +## Data flow between server and client + +### Rule: server loads should do exactly one job + +A `+page.server.ts` load function has one of two jobs — pick the right one: + +| Page type | Server load job | Client job | +|---|---|---| +| **List page** (CwDataTable) | Auth-gate only — return `{}` | `loadData` callback fetches, filters, sorts | +| **Detail / create / edit page** | Fetch the record(s) the page renders | Bind to `data.*`, submit via action | + +Never pre-fetch list data server-side that the `CwDataTable.loadData` callback will immediately re-fetch client-side. The server work is wasted and the data is thrown away. + +```ts +// ✅ List page — server does nothing except exist (auth gate is in hooks/layout) +export const load: PageServerLoad = async () => { + return {}; +}; + +// ✅ Detail page — server fetches the specific record +export const load: PageServerLoad = async ({ params, locals, fetch }) => { + const authToken = locals.jwtString ?? null; + if (!authToken) return { record: null }; + const api = new ApiService({ fetchFn: fetch, authToken }); + return { record: await api.getRecord(params.id).catch(() => null) }; +}; +``` + +### Rule: always use `PageServerLoad`, never `LayoutServerLoad`, in `+page.server.ts` + +The generated `$types` file in each route folder exports the correct type. Importing from a +parent folder (`'../$types'`) gives you the layout type, which is wrong for a page. + +```ts +// ✅ +import type { PageServerLoad } from './$types'; +export const load: PageServerLoad = async () => { ... }; + +// ❌ +import type { LayoutServerLoad } from '../$types'; +export const load: LayoutServerLoad = async () => { ... }; +``` + +### Rule: always pass `fetchFn: fetch` when constructing ApiService on the server + +Without it the server uses the bare global `fetch` instead of SvelteKit's context-aware +fetch, losing request deduplication, cookie forwarding, and relative-URL resolution. + +```ts +// ✅ server-side +const api = new ApiService({ fetchFn: fetch, authToken }); + +// ✅ client-side (no fetchFn — global fetch is correct here) +const api = new ApiService({ authToken: app.accessToken }); +``` + +### Rule: child pages get `authToken` via `await parent()`, not by re-reading `locals` + +The root layout (`+layout.server.ts`) is the single source of auth. Layout files that own a +route subtree (e.g. the device layout) forward `authToken` explicitly in their return value. +Page servers under that layout call `await parent()` to receive it. + +```ts +// ✅ device/+page.server.ts — gets auth from the device layout +export const load: PageServerLoad = async ({ fetch, params, parent }) => { + const { authToken, device } = await parent(); // device layout supplies both + ... +}; + +// ✅ server actions always read locals directly (actions don't call parent()) +export const actions: Actions = { + default: async ({ request, locals, fetch }) => { + const authToken = locals.jwtString ?? null; + ... + } +}; +``` + +### Rule: reuse parent layout data rather than re-fetching + +If a layout already loaded a record (e.g. the device layout fetches the device), child page +servers should consume it from `parent()` instead of calling the API again. + +```ts +// ✅ Settings page reuses the device the layout already fetched +const { authToken, device } = await parent(); +// No second api.getDevice() call needed +``` + +### Rule: `authToken` is available on every route via layout inheritance + +The root layout returns `authToken` for every route. Client components should read it via +`app.accessToken` (from `getAppContext()`), not by re-returning it in list-page server loads. + +--- + +## i18n + +All user-visible strings must use Paraglide message keys. Never hard-code English strings in templates. + +```ts +import { m } from '$lib/paraglide/messages.js'; + +// In templates: +{m.page_title()} + + +// In server actions: +return fail(400, { error: m.validation_name_required() }); +``` + +The only exception is debug/developer-only messages (`console.error`, comments). + +--- + +## Layout component reference + +| Component | Purpose | +|---|---| +| `` | Full-page wrapper; sets max-width and padding | +| `` | Vertical flex stack for form fields; `padded` adds inner spacing | +| `` | Horizontal row for Cancel + Submit buttons, right-aligned | +| `` | Inline feedback box (error, warning, info, success, neutral) | +| `` | Generic vertical flex section | + +Import all from `$lib/components/layout`: +```ts +import { AppActionRow, AppFormStack, AppNotice, AppPage, AppSection } from '$lib/components/layout'; +``` + +--- + +## File conventions + +- `+page.svelte` — UI only; no direct DB/API calls (exception: complex wizard forms) +- `+page.server.ts` — `load` function + `actions`; always exports typed `PageServerLoad` / `Actions` +- `+layout.svelte` / `+layout.server.ts` — shared shell, loaded once per layout boundary +- Feature-local sub-components live alongside the page file (e.g., `LocationUpdate.svelte` next to the settings `+page.svelte`) +- Shared reusable components go in `src/lib/components/` + +--- + +## Checklist for new pages + +### UI +- [ ] Root element is `` +- [ ] If navigated "into": back button is first child of AppPage, `variant="ghost" size="sm"` +- [ ] Form fields are wrapped in `` inside a `` +- [ ] Errors use ``, not a raw `

    ` +- [ ] Submit/cancel use `` as the last item in the form stack +- [ ] All strings use `m.key()` from Paraglide +- [ ] Field state uses `$state(...)`, not `$derived(...)`, for mutable form values +- [ ] No raw `

    ` or hard-coded pixel values outside a ` + diff --git a/src/routes/locations/[location_id]/+page.server.ts b/src/routes/locations/[location_id]/+page.server.ts index 5d63b438..277f400e 100644 --- a/src/routes/locations/[location_id]/+page.server.ts +++ b/src/routes/locations/[location_id]/+page.server.ts @@ -3,40 +3,40 @@ import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals, fetch, params }) => { const authToken = locals.jwtString ?? null; + const userId = locals.jwt?.sub ?? null; const locationId = Number.parseInt(params.location_id, 10); if (!authToken || !Number.isFinite(locationId)) { return { allLocationDevices: [], - currentLocation: null + currentLocation: null, + hasSettings: false }; } - const apiServiceInstance = new ApiService({ - fetchFn: fetch, - authToken - }); + const api = new ApiService({ fetchFn: fetch, authToken }); const [allLocationDevices, currentLocation] = await Promise.all([ - apiServiceInstance - .getAllLocationDevices(locationId) + api.getAllLocationDevices(locationId) .then((res) => res.data ?? []) .catch(() => []), - apiServiceInstance - .getLocation(locationId) + api.getLocation(locationId) .then((location) => location ?? null) - .catch(() => null), + .catch(() => null) ]); - let hasSettings = false; - if (currentLocation) { - - } + // Show the settings link when the current user is an owner of this location + // (permission_level 1 = owner, 2 = manager; the settings page enforces its own auth). + const owners = Array.isArray(currentLocation?.cw_location_owners) + ? currentLocation.cw_location_owners + : []; + const currentOwner = userId ? owners.find((o) => o.user_id === userId) : null; + const hasSettings = currentOwner != null && Number(currentOwner.permission_level) <= 2; return { allLocationDevices, - currentLocation: currentLocation?.name ?? 'Not Found', + currentLocation: currentLocation?.name ?? null, location: currentLocation, - hasSettings, + hasSettings }; }; diff --git a/src/routes/locations/[location_id]/+page.svelte b/src/routes/locations/[location_id]/+page.svelte index b645f6f5..86276d1f 100644 --- a/src/routes/locations/[location_id]/+page.svelte +++ b/src/routes/locations/[location_id]/+page.svelte @@ -1,5 +1,6 @@ - goto(`/`)}>← {m.action_back_to_dashboard()} + + goto(resolve('/'))}> + ← {m.action_back_to_dashboard()} + -
    - + {#snippet toolbarActions()} -
    +
    {#if selectedLocationId} goto(`/locations/${encodeURIComponent(+selectedLocationId)}/settings`)} + onclick={() => + goto(`/locations/${encodeURIComponent(+selectedLocationId)}/settings`)} > @@ -168,7 +170,7 @@ {/snippet} {#snippet rowActions(row: LocationDeviceRow)} -
    +
    handleViewDevice(row)}> @@ -178,7 +180,6 @@ variant="secondary" onclick={() => { if (!selectedLocationId) return; - goto( `/locations/${encodeURIComponent(selectedLocationId)}/devices/${encodeURIComponent(row.dev_eui)}/settings` ); @@ -191,16 +192,4 @@ {/snippet} -
    - - + diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/+layout.server.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/+layout.server.ts index 2d3f53f2..9978174e 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/+layout.server.ts +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+layout.server.ts @@ -17,6 +17,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => { if (!authToken || !dev_eui) { return { + authToken: null, devEui: dev_eui ?? '', locationId: location_id ?? '', device: null, @@ -44,15 +45,17 @@ export const load: LayoutServerLoad = async ({ params, locals }) => { const deviceDataTable = device.cw_device_type.data_table_v2; return { + authToken, devEui: dev_eui, locationId: location_id, device, - dataTable: deviceDataTable, + dataTable: deviceDataTable, permissionLevel }; } catch (err) { console.error(`[layout] Failed to load device ${dev_eui}:`, err); return { + authToken, devEui: dev_eui, locationId: location_id, device: null, diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.server.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.server.ts index 1692f86e..199bacd6 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.server.ts +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.server.ts @@ -1,4 +1,5 @@ import { ApiServiceError, type PaginationQuery, ApiService } from '$lib/api/api.service'; +import { readApiErrorMessage } from '$lib/api/api-error'; import { m } from '$lib/paraglide/messages.js'; import { fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; @@ -85,7 +86,7 @@ export const actions: Actions = { const responsePayload = error instanceof ApiServiceError ? error.payload : error; return fail(error instanceof ApiServiceError ? error.status : 502, { action: 'saveDataNote', - message: readApiError(responsePayload, m.devices_save_note_failed()) + message: readApiErrorMessage(responsePayload, m.devices_save_note_failed()) }); } @@ -96,19 +97,3 @@ export const actions: Actions = { } }; -function readApiError(payload: unknown, fallback: string): string { - if (payload && typeof payload === 'object') { - const payloadRecord = payload as Record; - const message = payloadRecord.message; - if (typeof message === 'string' && message.length > 0) return message; - if (Array.isArray(message)) { - const text = message - .filter((m): m is string => typeof m === 'string' && m.length > 0) - .join(', '); - if (text) return text; - } - - return readApiError(payloadRecord.payload, fallback); - } - return fallback; -} diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte index 99170d41..9fd50bdb 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte @@ -1,10 +1,12 @@ {m.devices_create_page_title()} -
    + + + goto(resolve('/locations/[location_id]', { location_id: locationId }))} + > + ← {m.action_back()} + +
    { submitting = true; @@ -168,14 +161,17 @@ }; }} > - {#if actionForm?.error} -

    {actionForm.error}

    - {/if} + + {#if actionForm?.error} + +

    {actionForm.error}

    +
    + {/if} - + -
    -
    + +
    - -
    -
    -
    -
    -

    {m.devices_deployment_section_title()}

    -
    + +
    +

    {m.devices_deployment_section_title()}

    -
    +
    -
    -
    -

    {m.devices_installed_at_label()}

    - - -
    +
    +

    {m.devices_installed_at_label()}

    +
    -
    - -
    - goto(resolve('/locations/[location_id]', { location_id: locationId }))} - > - - {m.action_cancel()} - - {m.devices_create_submit()} -
    + + + goto(resolve('/locations/[location_id]', { location_id: locationId }))} + > + {m.action_cancel()} + + + {m.devices_create_submit()} + + +
    -
    - - + diff --git a/src/routes/locations/[location_id]/settings/+page.svelte b/src/routes/locations/[location_id]/settings/+page.svelte index c8958842..e26d2109 100644 --- a/src/routes/locations/[location_id]/settings/+page.svelte +++ b/src/routes/locations/[location_id]/settings/+page.svelte @@ -1,4 +1,5 @@ -
    + + goto(resolve('/locations'))}> + ← {m.action_back()} + +
    -
    + {#if form?.error} -

    {form.error}

    + +

    {form.error}

    +
    {/if} -
    +
    -
    -
    - goto('/locations')}> - - {m.action_cancel()} - - - {m.locations_create_submit()} - -
    + + goto(resolve('/locations'))}> + {m.action_cancel()} + + + {m.locations_create_submit()} + + +
    -
    - - +
    diff --git a/src/routes/reports/+page.server.ts b/src/routes/reports/+page.server.ts index 864f5757..6b7ae945 100644 --- a/src/routes/reports/+page.server.ts +++ b/src/routes/reports/+page.server.ts @@ -1,56 +1,9 @@ -import { ApiService } from '$lib/api/api.service'; -import { formatDateTime } from '$lib/i18n/format'; -import { m } from '$lib/paraglide/messages.js'; -import type { ReportDeviceRelations, ReportRow } from './report-row'; import type { PageServerLoad } from './$types'; -type ReportApiRow = ReportDeviceRelations & { - created_at: string; -}; - -export const load: PageServerLoad = async ({ locals, fetch }) => { - const session = locals.jwt ?? null; - const authToken = locals.jwtString ?? null; - - if (!authToken) { - return { - session, - devices: [], - totalDeviceCount: 0, - triggeredRulesCount: 0 - }; - } - - const apiServiceInstance = new ApiService({ - fetchFn: fetch, - authToken - }); - - const reports = await apiServiceInstance.getReports().catch(() => []); - - const reportsResult: ReportRow[] = reports.map((report) => { - const reportWithRelations = report as typeof report & ReportApiRow; - const deviceOwners = reportWithRelations.cw_devices?.cw_device_owners ?? []; - - return { - ...reportWithRelations, - permission_level: - deviceOwners.find( - (owner) => - owner.user_id === session?.sub && - owner.permission_level != null && - owner.permission_level <= 3 - )?.permission_level ?? null, - created_at: formatDateTime(reportWithRelations.created_at), - location_name: - reportWithRelations.cw_devices?.cw_locations?.name ?? m.reports_unknown_location(), - device_name: reportWithRelations.cw_devices?.name ?? m.reports_unknown_device() - }; - }); - - return { - session, - authToken, - reports: reportsResult - }; +// The reports list page uses CwDataTable with a client-side loadData callback that +// fetches, transforms, and paginates reports on demand. Pre-fetching here would +// duplicate that work and be thrown away immediately. The root layout supplies +// `authToken` and `session` to every route via layout data inheritance. +export const load: PageServerLoad = async () => { + return {}; }; diff --git a/src/routes/reports/+page.svelte b/src/routes/reports/+page.svelte index 96e3b96b..3f4568e1 100644 --- a/src/routes/reports/+page.svelte +++ b/src/routes/reports/+page.svelte @@ -23,7 +23,6 @@ type ReportApiRow = ReportDeviceRelations & { created_at: string }; - let { data }: { data: { reports: ReportRow[] } } = $props(); let loading = $state(true); let deletedReportIds = $state([]); let app = getAppContext(); diff --git a/src/routes/reports/[report_id]/edit/+page.svelte b/src/routes/reports/[report_id]/edit/+page.svelte index 2034a42e..fc1698d8 100644 --- a/src/routes/reports/[report_id]/edit/+page.svelte +++ b/src/routes/reports/[report_id]/edit/+page.svelte @@ -720,8 +720,8 @@
    - goto(resolve('/reports'))}> - {m.action_back()} + goto(resolve('/reports'))}> + ← {m.action_back()}
    { - const session = locals.jwt ?? null; - const authToken = locals.jwtString ?? null; - - if (!authToken) { - return { - session, - devices: [], - totalDeviceCount: 0, - triggeredRulesCount: 0 - }; - } - - const apiServiceInstance = new ApiService({ - fetchFn: fetch, - authToken - }); - - const rules = await apiServiceInstance.getRules().catch(() => []); - - const ruleResult = rules.map((rule) => { - const deviceRecord = - rule.cw_devices && typeof rule.cw_devices === 'object' - ? (rule.cw_devices as Record) - : null; - const deviceOwners = Array.isArray(deviceRecord?.cw_device_owners) - ? (deviceRecord.cw_device_owners as Array<{ - user_id?: string | null; - permission_level?: number | null; - }>) - : []; - const locationRecord = - deviceRecord?.cw_locations && typeof deviceRecord.cw_locations === 'object' - ? (deviceRecord.cw_locations as Record) - : null; - - return { - device_name: - deviceRecord?.name && typeof deviceRecord.name === 'string' ? deviceRecord.name : '', - location_name: - typeof locationRecord?.name === 'string' && locationRecord.name.trim().length > 0 - ? locationRecord.name - : '', - hasPermission: rule.hasPermission || false, - permission_level: - deviceOwners.find((owner) => owner.user_id === session?.sub)?.permission_level ?? null, - - last_triggered: rule.last_triggered ? new Date(rule.last_triggered) : null, - ...rule - }; - }); - - return { - session, - authToken, - rules: ruleResult - }; +import type { PageServerLoad } from './$types'; + +// The rules list page uses CwDataTable with a client-side loadData callback that +// fetches, transforms, and paginates rules on demand. Pre-fetching here would +// duplicate that work and be thrown away immediately. The root layout supplies +// `authToken` and `session` to every route via layout data inheritance. +export const load: PageServerLoad = async () => { + return {}; }; diff --git a/src/routes/rules/+page.svelte b/src/routes/rules/+page.svelte index eaabb840..78d3ad9c 100644 --- a/src/routes/rules/+page.svelte +++ b/src/routes/rules/+page.svelte @@ -26,7 +26,6 @@ permission_level: number | null; }; - let { data }: { data: { rules: RuleRow[] } } = $props(); let loading = $state(false); let deletedRuleGroupIds = $state([]); let tableKey = $derived(deletedRuleGroupIds.join(',')); diff --git a/src/routes/rules/create/+page.svelte b/src/routes/rules/create/+page.svelte index abef00b9..25aa4e93 100644 --- a/src/routes/rules/create/+page.svelte +++ b/src/routes/rules/create/+page.svelte @@ -12,7 +12,6 @@ import { getRuleNotifierTypeOptions, getRuleOperatorOptions, - getRuleSendMethodOptions, getRuleSubjectOptions } from '$lib/i18n/options'; import { goto } from '$app/navigation'; @@ -30,7 +29,6 @@ // ── Constants ──────────────────────────────────────────────────────────── const OPERATORS = getRuleOperatorOptions(); const NOTIFIER_TYPES = getRuleNotifierTypeOptions(); - const SEND_METHODS = getRuleSendMethodOptions(); const SUBJECT_OPTIONS = getRuleSubjectOptions(); // ── Form state ────────────────────────────────────────────────────────── @@ -164,9 +162,9 @@ - goto(resolve('/rules'))} - >← {m.action_back()} + goto(resolve('/rules'))}> + ← {m.action_back()} +
    From eb49799b0475bafe5ef8ee16763207016ceee719 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 16 Apr 2026 23:40:52 +0900 Subject: [PATCH 2/3] about to do a big change --- e2e/header-layout.test.ts | 166 +++++++++ package.json | 2 +- pnpm-lock.yaml | 10 +- .../dashboard/DashboardDeviceCards.svelte | 235 ++++++++----- .../DashboardDeviceCards.svelte.spec.ts | 27 +- .../dashboard/DashboardDeviceTable.svelte | 12 +- .../dashboard/DashboardTable.svelte | 322 ++++++++++++++++++ .../dashboard/dashboard-device-data.ts | 131 ++----- .../dashboard/dashboard-device-refresh.ts | 17 +- .../dashboard/dashboard-filter-values.ts | 19 +- .../components/dashboard/device-cards.spec.ts | 16 +- src/lib/components/dashboard/device-cards.ts | 140 ++++---- src/lib/components/dashboard/device-table.ts | 19 +- src/lib/components/layout/AppPage.svelte | 16 +- src/routes/+page.server.ts | 6 +- src/routes/+page.svelte | 72 ++-- src/routes/Header.svelte | 117 +++++-- src/routes/Sidebar.svelte | 50 ++- src/routes/layout.css | 32 ++ 19 files changed, 1034 insertions(+), 375 deletions(-) create mode 100644 e2e/header-layout.test.ts create mode 100644 src/lib/components/dashboard/DashboardTable.svelte diff --git a/e2e/header-layout.test.ts b/e2e/header-layout.test.ts new file mode 100644 index 00000000..31aad521 --- /dev/null +++ b/e2e/header-layout.test.ts @@ -0,0 +1,166 @@ +import { expect, test, type Page } from '@playwright/test'; + +const baseUrl = 'http://127.0.0.1:4173'; + +function createJwt() { + const encode = (value: Record) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + + return [ + encode({ alg: 'HS256', typ: 'JWT' }), + encode({ + sub: 'playwright-user', + email: 'operator@cropwatch.test', + role: 'operator', + exp: Math.floor(Date.now() / 1000) + 60 * 60, + user_metadata: { + full_name: 'Playwright Operator', + name: 'playwright-operator' + } + }), + 'test-signature' + ].join('.'); +} + +async function signIn(page: Page) { + await page.context().addCookies([ + { + name: 'jwt', + value: createJwt(), + url: baseUrl + } + ]); +} + +async function openAuthenticatedSettings(page: Page, viewport: { width: number; height: number }) { + await page.setViewportSize(viewport); + await signIn(page); + await page.goto('/settings'); + await expect(page.locator('.app-header')).toBeVisible(); +} + +async function readHeaderMetrics(page: Page) { + return page.evaluate(() => { + function readRect(selector: string) { + const element = document.querySelector(selector); + if (!element) { + return null; + } + + const { x, y, top, right, bottom, left, width, height } = + element.getBoundingClientRect(); + return { x, y, top, right, bottom, left, width, height }; + } + + const utilityGroup = document.querySelector('.app-header__utility-group'); + const navUtilities = document.querySelector('.app-sidebar__mobile-utilities'); + + return { + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + header: readRect('.app-header'), + actions: readRect('.app-header .cw-header__actions'), + profileTrigger: readRect('.app-header .cw-profile-menu__trigger'), + profileDropdown: readRect('.cw-profile-menu__dropdown'), + utilityGroupVisible: + !!utilityGroup && + getComputedStyle(utilityGroup).display !== 'none' && + utilityGroup.getBoundingClientRect().width > 0, + navUtilitiesVisible: + !!navUtilities && + getComputedStyle(navUtilities).display !== 'none' && + navUtilities.getBoundingClientRect().width > 0 + }; + }); +} + +function expectRectWithinViewport( + rect: { + left: number; + right: number; + top: number; + bottom: number; + } | null, + viewport: { width: number; height: number } +) { + expect(rect).not.toBeNull(); + expect(rect!.left).toBeGreaterThanOrEqual(-1); + expect(rect!.top).toBeGreaterThanOrEqual(-1); + expect(rect!.right).toBeLessThanOrEqual(viewport.width + 1); + expect(rect!.bottom).toBeLessThanOrEqual(viewport.height + 1); +} + +for (const viewport of [ + { name: 'iphone6', width: 320, height: 568 }, + { name: 'iphonexs', width: 375, height: 812 } +]) { + test(`mobile header stays within the viewport on ${viewport.name}`, async ({ page }) => { + test.skip(test.info().project.name !== 'webkit-mobile', 'mobile viewport coverage only'); + + await openAuthenticatedSettings(page, viewport); + + const initialMetrics = await readHeaderMetrics(page); + expectRectWithinViewport(initialMetrics.header, { + width: initialMetrics.viewportWidth, + height: initialMetrics.viewportHeight + }); + expectRectWithinViewport(initialMetrics.actions, { + width: initialMetrics.viewportWidth, + height: initialMetrics.viewportHeight + }); + expectRectWithinViewport(initialMetrics.profileTrigger, { + width: initialMetrics.viewportWidth, + height: initialMetrics.viewportHeight + }); + expect(initialMetrics.utilityGroupVisible).toBe(false); + + await page.locator('.cw-profile-menu__trigger').click(); + + const dropdownMetrics = await readHeaderMetrics(page); + expectRectWithinViewport(dropdownMetrics.profileDropdown, { + width: dropdownMetrics.viewportWidth, + height: dropdownMetrics.viewportHeight + }); + + await page.keyboard.press('Escape'); + await page.locator('.cw-header__hamburger').click(); + await expect(page.locator('.app-sidebar__mobile-utilities')).toBeVisible(); + + const navMetrics = await readHeaderMetrics(page); + expect(navMetrics.navUtilitiesVisible).toBe(true); + }); +} + +for (const viewport of [ + { name: 'tablet', width: 768, height: 1024 }, + { name: 'desktop', width: 1280, height: 900 } +]) { + test(`wide header keeps controls within the viewport on ${viewport.name}`, async ({ page }) => { + test.skip(test.info().project.name !== 'chromium', 'wide viewport coverage only'); + + await openAuthenticatedSettings(page, viewport); + + const initialMetrics = await readHeaderMetrics(page); + expectRectWithinViewport(initialMetrics.header, { + width: initialMetrics.viewportWidth, + height: initialMetrics.viewportHeight + }); + expectRectWithinViewport(initialMetrics.actions, { + width: initialMetrics.viewportWidth, + height: initialMetrics.viewportHeight + }); + expectRectWithinViewport(initialMetrics.profileTrigger, { + width: initialMetrics.viewportWidth, + height: initialMetrics.viewportHeight + }); + expect(initialMetrics.utilityGroupVisible).toBe(viewport.name === 'desktop'); + + await page.locator('.cw-profile-menu__trigger').click(); + + const dropdownMetrics = await readHeaderMetrics(page); + expectRectWithinViewport(dropdownMetrics.profileDropdown, { + width: dropdownMetrics.viewportWidth, + height: dropdownMetrics.viewportHeight + }); + }); +} diff --git a/package.json b/package.json index ac5df679..576cabd5 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ }, "packageManager": "pnpm@10.18.2+sha512.9fb969fa749b3ade6035e0f109f0b8a60b5d08a1a87fdf72e337da90dcc93336e2280ca4e44f2358a649b83c17959e9993e777c2080879f3801e6f0d999ad3dd", "dependencies": { - "@cropwatchdevelopment/cwui": "0.1.78", + "@cropwatchdevelopment/cwui": "0.1.80", "@supabase/supabase-js": "^2.98.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eee4c6e2..c16500a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@cropwatchdevelopment/cwui': - specifier: 0.1.78 - version: 0.1.78(svelte@5.53.0) + specifier: 0.1.80 + version: 0.1.80(svelte@5.53.0) '@supabase/supabase-js': specifier: ^2.98.0 version: 2.98.0 @@ -114,8 +114,8 @@ importers: packages: - '@cropwatchdevelopment/cwui@0.1.78': - resolution: {integrity: sha512-PCg/AkEyhGCCTp8G/21Lh2NIqi32wqk+8934EwK+xEWOBJ0/aNK9ofIJ8kgrG0bMEqja8wjLTB+ZhQ6yOniTtQ==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.78/f77f73821f24486f950a15c41f02e787309a158b} + '@cropwatchdevelopment/cwui@0.1.80': + resolution: {integrity: sha512-GxAM4CHhInGz7mlOthne1q9y9Zepdjb6xLNiB0wetVdDKxrK8KzYmYKhGYhbY7p2pNheTshbmOpwGXyu+S17Sg==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.80/7e3089a477c33f11969eb0c30e54e3cbe81a4ef9} peerDependencies: svelte: ^5.0.0 @@ -2074,7 +2074,7 @@ packages: snapshots: - '@cropwatchdevelopment/cwui@0.1.78(svelte@5.53.0)': + '@cropwatchdevelopment/cwui@0.1.80(svelte@5.53.0)': dependencies: svelte: 5.53.0 diff --git a/src/lib/components/dashboard/DashboardDeviceCards.svelte b/src/lib/components/dashboard/DashboardDeviceCards.svelte index c3014871..aa5239de 100644 --- a/src/lib/components/dashboard/DashboardDeviceCards.svelte +++ b/src/lib/components/dashboard/DashboardDeviceCards.svelte @@ -1,6 +1,11 @@ + +
    + + {#await initialTable} + + {:then initialTableState} + loadData(query, initialTableState)} + filters={tableFilters} + rowKey="dev_eui" + pageSize={initialTableState.query.pageSize} + pageSizeOptions={[25, 50, 100]} + searchable + groupBy="location_name" + fillParent + onRefresh={handleRefresh} + rowActionsHeader="Actions" + > + {#snippet cell( + row: DevicePrimaryDataDto, + col: CwColumnDef, + defaultValue: string + )} + {#if col.key === 'name'} + {row.name?.trim() || row.dev_eui} + {:else if col.key === 'temperature_c'} + {formatMetric(row.temperature_c)} + {:else if col.key === 'humidity'} + {formatMetric(row.humidity)} + {:else if col.key === 'co2'} + {formatMetric(row.co2)} + {:else if col.key === 'moisture'} + {formatMetric(row.moisture)} + {:else if col.key === 'created_at'} + + + + + {:else} + {defaultValue} + {/if} + {/snippet} + + {#snippet actionsHeader()} + void handleRefresh()}> + Refresh all + + {/snippet} + + {#snippet rowActions(row: DevicePrimaryDataDto)} + openDevice(row)}>View + {/snippet} + + {#snippet emptyState()} + +

    Try clearing one or more filters or refreshing the dashboard data.

    +
    + {/snippet} + + {#snippet errorState(message: string)} + +

    {message}

    +
    + {/snippet} +
    + {/await} +
    +
    + + diff --git a/src/lib/components/dashboard/dashboard-device-data.ts b/src/lib/components/dashboard/dashboard-device-data.ts index d9fa4b2c..2973f8ab 100644 --- a/src/lib/components/dashboard/dashboard-device-data.ts +++ b/src/lib/components/dashboard/dashboard-device-data.ts @@ -5,32 +5,6 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function toStringValue(value: unknown): string { - return typeof value === 'string' ? value : value == null ? '' : String(value); -} - -function toNumberValue(value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) ? value : Number(value ?? 0) || 0; -} - -function toOptionalNumberValue(value: unknown): number | null { - if (value == null || value === '') { - return null; - } - - const nextValue = typeof value === 'number' && Number.isFinite(value) ? value : Number(value); - return Number.isFinite(nextValue) ? nextValue : null; -} - -function toDateValue(value: unknown): Date { - if (value instanceof Date) { - return value; - } - - const nextValue = new Date(toStringValue(value)); - return Number.isNaN(nextValue.getTime()) ? new Date(0) : nextValue; -} - function getDashboardEmbeddedLocations( device: DeviceDto | Record ): Record[] { @@ -46,32 +20,22 @@ function getDashboardEmbeddedLocations( } function getDashboardDeviceLocationName(device: DeviceDto | Record): string { - const directName = toStringValue(device.location_name).trim(); - if (directName) { - return directName; - } + if (device.location_name) return device.location_name as string; for (const location of getDashboardEmbeddedLocations(device)) { - const locationName = toStringValue(location?.name).trim(); - if (locationName) { - return locationName; - } + if (location?.name) return location.name as string; } return ''; } function getDashboardDeviceLocationId(device: DeviceDto | Record): number { - const directId = toNumberValue(device.location_id); - if (directId > 0) { - return directId; - } + const directId = Number(device.location_id); + if (directId > 0) return directId; for (const location of getDashboardEmbeddedLocations(device)) { - const locationId = toNumberValue(location.location_id); - if (locationId > 0) { - return locationId; - } + const id = Number(location.location_id); + if (id > 0) return id; } return directId; @@ -91,25 +55,16 @@ function preferIncomingLocationId(incoming: number, current: number): number { function getDeviceDataTable( device: DeviceDto | DevicePrimaryDataDto | Record ): string { - const directDataTable = toStringValue(device.data_table_v2 ?? device.data_table).trim(); - if (directDataTable) { - return directDataTable; - } + const directDataTable = device.data_table_v2 ?? device.data_table; + if (directDataTable) return directDataTable as string; if (isRecord(device.cw_device_type)) { - return toStringValue( - device.cw_device_type.data_table_v2 ?? device.cw_device_type.data_table - ).trim(); + return (device.cw_device_type.data_table_v2 ?? device.cw_device_type.data_table ?? '') as string; } return ''; } -function toOptionalStringValue(value: unknown): string | undefined { - if (value == null || value === '') return undefined; - return String(value); -} - function extractDeviceTypeId( device: DeviceDto | DevicePrimaryDataDto | Record ): number | undefined { @@ -124,10 +79,6 @@ export interface DeviceTypeConfig { secondary_data_key?: string; primary_data_notation?: string; secondary_data_notation?: string; - primary_multiplier?: number | null; - primary_divider?: number | null; - secondary_multiplier?: number | null; - secondary_divider?: number | null; } /** Lookup maps built from the `/devices/device-types` endpoint. */ @@ -143,32 +94,24 @@ export interface DeviceTypeLookup { * Keyed by `cw_device_type.model` with an id→model bridge so individual * devices can resolve their type via their numeric FK (`device.type`). */ -export function buildDeviceTypeLookup( - deviceTypes: DeviceTypeDto[] -): DeviceTypeLookup { +export function buildDeviceTypeLookup(deviceTypes: DeviceTypeDto[]): DeviceTypeLookup { const byModel: Record = {}; const idToModel: Record = {}; for (const dt of deviceTypes) { - const model = toOptionalStringValue(dt.model); + const model = dt.model || undefined; if (!model) continue; const id = typeof dt.id === 'number' && Number.isFinite(dt.id) ? dt.id : undefined; - if (id != null) { - idToModel[id] = model; - } + if (id != null) idToModel[id] = model; if (byModel[model]) continue; byModel[model] = { - primary_data_key: toOptionalStringValue(dt.primary_data_v2), - secondary_data_key: toOptionalStringValue(dt.secondary_data_v2), - primary_data_notation: toOptionalStringValue(dt.primary_data_notation), - secondary_data_notation: toOptionalStringValue(dt.secondary_data_notation), - primary_multiplier: toOptionalNumberValue(dt.primary_multiplier), - primary_divider: toOptionalNumberValue(dt.primary_divider), - secondary_multiplier: toOptionalNumberValue(dt.secondary_multiplier), - secondary_divider: toOptionalNumberValue(dt.secondary_divider) + primary_data_key: dt.primary_data_v2 ?? undefined, + secondary_data_key: dt.secondary_data_v2 ?? undefined, + primary_data_notation: dt.primary_data_notation ?? undefined, + secondary_data_notation: dt.secondary_data_notation ?? undefined, }; } @@ -188,14 +131,12 @@ export function resolveDeviceTypeConfig( export function mapDashboardDeviceMetadataToDevice( device: DeviceDto | Record ): IDevice { - const dataTable = getDeviceDataTable(device); - return { - dev_eui: toStringValue(device.dev_eui), - name: toStringValue(device.name ?? device.dev_eui), + dev_eui: device.dev_eui as string, + name: (device.name ?? device.dev_eui) as string, location_name: getDashboardDeviceLocationName(device), - group: toStringValue(device.group), - data_table: dataTable || undefined, + group: device.group as string, + data_table: getDeviceDataTable(device) || undefined, created_at: new Date(0), has_primary_data: false, co2: 0, @@ -212,7 +153,8 @@ export function mapDashboardPrimaryDataToDevice( device: DevicePrimaryDataDto | Record ): IDevice { const dataTable = getDeviceDataTable(device); - const soilHumidity = toOptionalNumberValue(device.moisture); + const moisture = device.moisture; + const soilHumidity = moisture != null ? Number(moisture) : null; // Preserve the full raw payload so dynamic column keys can be resolved const raw_data: Record = {}; @@ -222,19 +164,22 @@ export function mapDashboardPrimaryDataToDevice( } } + const createdAt = device.created_at; + const parsedDate = createdAt instanceof Date ? createdAt : new Date(createdAt as string); + return { - dev_eui: toStringValue(device.dev_eui), - name: toStringValue(device.name), - location_name: toStringValue(device.location_name), - group: toStringValue(device.group), + dev_eui: device.dev_eui as string, + name: device.name as string, + location_name: device.location_name as string, + group: device.group as string, ...(dataTable ? { data_table: dataTable } : {}), - created_at: toDateValue(device.created_at), + created_at: parsedDate, has_primary_data: true, - co2: toNumberValue(device.co2), - humidity: toNumberValue(device.humidity), - temperature_c: toNumberValue(device.temperature_c), + co2: Number(device.co2) || 0, + humidity: Number(device.humidity) || 0, + temperature_c: Number(device.temperature_c) || 0, soil_humidity: soilHumidity, - location_id: toNumberValue(device.location_id), + location_id: Number(device.location_id) || 0, cwloading: false, raw_data, device_type_id: extractDeviceTypeId(device) @@ -272,17 +217,13 @@ export function mergeDashboardDevices( currentDevices: IDevice[], latestDevices: IDevice[] ): IDevice[] { - if (latestDevices.length === 0) { - return currentDevices; - } + if (latestDevices.length === 0) return currentDevices; const latestByDevEui = new Map(latestDevices.map((device) => [device.dev_eui, device] as const)); const mergedDevices = currentDevices.map((device) => { const latestDevice = latestByDevEui.get(device.dev_eui); - if (!latestDevice) { - return device; - } + if (!latestDevice) return device; latestByDevEui.delete(device.dev_eui); const resolvedDataTable = preferIncomingText(latestDevice.data_table, device.data_table); diff --git a/src/lib/components/dashboard/dashboard-device-refresh.ts b/src/lib/components/dashboard/dashboard-device-refresh.ts index 42473511..df2bbc48 100644 --- a/src/lib/components/dashboard/dashboard-device-refresh.ts +++ b/src/lib/components/dashboard/dashboard-device-refresh.ts @@ -35,16 +35,25 @@ function getDeviceTimestampMs(device: Pick): number { } export function isDashboardDeviceOffline( - device: Pick + device: Pick & { raw_data?: Record } ): boolean { if (device.has_primary_data === false) { return true; } const lastSeenMs = getDeviceTimestampMs(device); - return ( - !Number.isFinite(lastSeenMs) || lastSeenMs < Date.now() - DASHBOARD_DEVICE_OFFLINE_THRESHOLD_MS - ); + if (!Number.isFinite(lastSeenMs)) return true; + + const intervalMinutes = + typeof device.raw_data?.default_upload_interval === 'number' + ? device.raw_data.default_upload_interval + : null; + const thresholdMs = + intervalMinutes != null && intervalMinutes > 0 + ? intervalMinutes * 60_000 + : DASHBOARD_DEVICE_OFFLINE_THRESHOLD_MS; + + return lastSeenMs < Date.now() - thresholdMs; } export function getDashboardDeviceNextRefreshDelayMs( diff --git a/src/lib/components/dashboard/dashboard-filter-values.ts b/src/lib/components/dashboard/dashboard-filter-values.ts index d33713ff..33336867 100644 --- a/src/lib/components/dashboard/dashboard-filter-values.ts +++ b/src/lib/components/dashboard/dashboard-filter-values.ts @@ -7,25 +7,14 @@ const DASHBOARD_FILTER_VALUE_KEYS = [ 'label' ] as const; -function toTrimmedString(value: unknown): string { - return typeof value === 'string' ? value.trim() : ''; -} - export function readDashboardFilterValue(value: unknown): string { - if (typeof value === 'string') { - return value.trim(); - } - - if (!value || typeof value !== 'object') { - return ''; - } + if (typeof value === 'string') return value.trim(); + if (!value || typeof value !== 'object') return ''; const record = value as Record; for (const key of DASHBOARD_FILTER_VALUE_KEYS) { - const candidate = toTrimmedString(record[key]); - if (candidate) { - return candidate; - } + const candidate = typeof record[key] === 'string' ? (record[key] as string).trim() : ''; + if (candidate) return candidate; } return ''; diff --git a/src/lib/components/dashboard/device-cards.spec.ts b/src/lib/components/dashboard/device-cards.spec.ts index 806a9751..dac645cf 100644 --- a/src/lib/components/dashboard/device-cards.spec.ts +++ b/src/lib/components/dashboard/device-cards.spec.ts @@ -60,11 +60,13 @@ describe('device-cards helpers', () => { const cards = buildDashboardLocationSensorCards(devices, locations); expect(cards.map((card) => card.title)).toEqual(['Atrium', 'Zone B']); - expect(cards[1]?.devices.map((device) => device.label)).toEqual([ + expect(cards[1]?.sensors.map(({ sensor }) => sensor.label)).toEqual([ 'Canopy (dev-1)', 'Canopy (dev-2)' ]); - expect(cards[1]?.deviceBindingsByLabel['Canopy (dev-1)']).toEqual({ + expect(cards[1]?.sensors[0]).toMatchObject({ + id: 'sensor:dev-1', + storageKey: 'dashboard-device-card:dev-1', devEui: 'dev-1', locationId: 2, sourceDevice: devices[1] @@ -109,17 +111,17 @@ describe('device-cards helpers', () => { ] ); - expect(cards[0]?.devices[0]).toMatchObject({ + expect(cards[0]?.sensors[0]?.sensor).toMatchObject({ label: 'Old Sensor', status: 'offline' }); - expect(cards[0]?.devices[1]).toMatchObject({ + expect(cards[0]?.sensors[1]?.sensor).toMatchObject({ label: 'Recent Sensor', status: 'online' }); - expect(cards[0]?.devices[1]?.expectedUpdateAfterMinutes).toBeUndefined(); + expect(cards[0]?.sensors[1]?.sensor.expectedUpdateAfterMinutes).toBeUndefined(); expect( - getDashboardDeviceNextRefreshDelayMs(cards[0]!.deviceBindingsByLabel['Old Sensor'].sourceDevice) + getDashboardDeviceNextRefreshDelayMs(cards[0]!.sensors[0]!.sourceDevice) ).not.toBeNull(); expect(DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES).toBe(10.3); @@ -151,7 +153,7 @@ describe('device-cards helpers', () => { ] ); - expect(cards[0]?.devices[0]).toMatchObject({ + expect(cards[0]?.sensors[0]?.sensor).toMatchObject({ label: 'No Data Yet', status: 'offline', detailRows: [ diff --git a/src/lib/components/dashboard/device-cards.ts b/src/lib/components/dashboard/device-cards.ts index c25b9f5c..27f591dc 100644 --- a/src/lib/components/dashboard/device-cards.ts +++ b/src/lib/components/dashboard/device-cards.ts @@ -14,19 +14,20 @@ import { export const DASHBOARD_SENSOR_CARD_LOCATION_BATCH_SIZE = 10; export const DASHBOARD_SENSOR_CARD_PREFETCH_REMAINING = 5; -export interface DashboardLocationSensorCardDeviceBinding { +export interface DashboardSensorCardEntry { + id: string; + storageKey: string; devEui: string; locationId: number; sourceDevice: IDevice; + sensor: CwSensorCardDevice; } export interface DashboardLocationSensorCard { id: string; - renderKey: string; locationId: number; title: string; - devices: CwSensorCardDevice[]; - deviceBindingsByLabel: Record; + sensors: DashboardSensorCardEntry[]; } function getDeviceBaseLabel(device: IDevice): string { @@ -38,26 +39,13 @@ function getDeviceLabel(device: IDevice, duplicateCounts: Map): return (duplicateCounts.get(baseLabel) ?? 0) > 1 ? `${baseLabel} (${device.dev_eui})` : baseLabel; } -function applyTransform(rawValue: unknown, multiplier?: number | null, divider?: number | null): number { - let value = typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0) || 0; - if (multiplier != null && multiplier !== 0) { - value *= multiplier; - } - if (divider != null && divider !== 0) { - value /= divider; - } - return value; -} function getDeviceStatus(device: IDevice): 'online' | 'offline' { return isDashboardDeviceOffline(device) ? 'offline' : 'online'; } -function toCardRenderKey(locationId: number, devices: IDevice[]): string { - return [ - `location:${locationId}`, - ...devices.map((device) => `${device.dev_eui}:${device.created_at.toISOString()}`) - ].join('|'); +function getSensorStorageKey(device: IDevice): string { + return `dashboard-device-card:${device.dev_eui}`; } function buildUnavailableDetailRows(label: string, typeConfig: DeviceTypeConfig | undefined): CwSensorCardDetailRow[] { @@ -125,6 +113,8 @@ const KNOWN_SENSOR_FIELDS: Record = { light: { label: 'Light', unit: 'lux', icon: 'thermo' }, light_level: { label: 'Light', unit: 'lux', icon: 'thermo' }, voltage: { label: 'Voltage', unit: 'V', icon: 'timer' }, + deapth_cm: { label: 'Depth', unit: 'cm', icon: 'drop' }, + depth_cm: { label: 'Depth', unit: 'cm', icon: 'drop' }, }; function formatKeyLabel(key: string): string { @@ -159,17 +149,13 @@ export function buildDeviceExpandedDetailRows( const isPrimary = typeConfig?.primary_data_key === key; const isSecondary = typeConfig?.secondary_data_key === key; - const displayValue = isPrimary - ? applyTransform(rawValue, typeConfig?.primary_multiplier, typeConfig?.primary_divider) - : isSecondary - ? applyTransform(rawValue, typeConfig?.secondary_multiplier, typeConfig?.secondary_divider) - : rawValue; + const displayValue = rawValue; const known = KNOWN_SENSOR_FIELDS[key]; const label = isPrimary - ? (typeConfig?.primary_data_key ?? known?.label ?? formatKeyLabel(key)) + ? (known?.label ?? typeConfig?.primary_data_key ?? formatKeyLabel(key)) : isSecondary - ? (typeConfig?.secondary_data_key ?? known?.label ?? formatKeyLabel(key)) + ? (known?.label ?? typeConfig?.secondary_data_key ?? formatKeyLabel(key)) : (known?.label ?? formatKeyLabel(key)); const unit = isPrimary ? (typeConfig?.primary_data_notation ?? known?.unit ?? '') @@ -208,6 +194,54 @@ export function buildDeviceExpandedDetailRows( return rows; } +function buildDashboardSensorCardEntry( + device: IDevice, + duplicateCounts: Map, + deviceTypeLookup?: DeviceTypeLookup +): DashboardSensorCardEntry { + const label = getDeviceLabel(device, duplicateCounts); + const typeConfig = resolveDeviceTypeConfig(device, deviceTypeLookup); + + if (device.has_primary_data === false) { + return { + id: `sensor:${device.dev_eui}`, + storageKey: getSensorStorageKey(device), + devEui: device.dev_eui, + locationId: Number(device.location_id), + sourceDevice: device, + sensor: { + label, + primaryValue: 0, + primaryUnit: typeConfig?.primary_data_notation ?? '°C', + status: 'offline', + detailRows: buildUnavailableDetailRows(label, typeConfig) + } satisfies CwSensorCardDevice + }; + } + + const rawPrimary = device.raw_data?.[typeConfig?.primary_data_key]; + const rawSecondary = device.raw_data?.[typeConfig?.secondary_data_key] || null; + + return { + id: `sensor:${device.dev_eui}`, + storageKey: getSensorStorageKey(device), + devEui: device.dev_eui, + locationId: Number(device.location_id), + sourceDevice: device, + sensor: { + label, + primaryValue: typeof rawPrimary === 'number' ? rawPrimary : Number(rawPrimary ?? 0) || 0, + primaryUnit: typeConfig?.primary_data_notation ?? '°C', + ...(rawSecondary !== null && { + secondaryValue: typeof rawSecondary === 'number' ? rawSecondary : Number(rawSecondary) || 0, + secondaryUnit: typeConfig?.secondary_data_notation ?? '%', + }), + status: getDeviceStatus(device), + lastUpdated: device.created_at + } satisfies CwSensorCardDevice + }; +} + export function buildDashboardLocationSensorCards( devices: IDevice[], locations: LocationDto[], @@ -250,61 +284,13 @@ export function buildDashboardLocationSensorCards( duplicateCounts.set(baseLabel, (duplicateCounts.get(baseLabel) ?? 0) + 1); } - const deviceBindingsByLabel: DashboardLocationSensorCard['deviceBindingsByLabel'] = {}; - const sensorDevices = sortedLocationDevices.map((device) => { - const label = getDeviceLabel(device, duplicateCounts); - - deviceBindingsByLabel[label] = { - devEui: device.dev_eui, - locationId: Number(device.location_id), - sourceDevice: device - }; - - if (device.has_primary_data === false) { - const typeConfig = resolveDeviceTypeConfig(device, deviceTypeLookup); - return { - label, - primaryValue: 0, - primaryUnit: typeConfig?.primary_data_notation ?? '°C', - status: 'offline', - detailRows: buildUnavailableDetailRows(label, typeConfig) - } satisfies CwSensorCardDevice; - } - - const typeConfig = resolveDeviceTypeConfig(device, deviceTypeLookup); - const primaryKey = typeConfig?.primary_data_key; - const secondaryKey = typeConfig?.secondary_data_key; - - // Resolve primary value: try dynamic key from raw_data, fall back to temperature_c - const rawPrimary = primaryKey && device.raw_data?.[primaryKey] !== undefined - ? device.raw_data[primaryKey] - : device.temperature_c; - const primaryValue = applyTransform(rawPrimary, typeConfig?.primary_multiplier, typeConfig?.primary_divider); - - // Resolve secondary value: try dynamic key from raw_data, fall back to humidity - const rawSecondary = secondaryKey && device.raw_data?.[secondaryKey] !== undefined - ? device.raw_data[secondaryKey] - : device.humidity; - const secondaryValue = applyTransform(rawSecondary, typeConfig?.secondary_multiplier, typeConfig?.secondary_divider); - - return { - label, - primaryValue, - primaryUnit: typeConfig?.primary_data_notation ?? '°C', - secondaryValue, - secondaryUnit: typeConfig?.secondary_data_notation ?? '%', - status: getDeviceStatus(device), - lastUpdated: device.created_at - } satisfies CwSensorCardDevice; - }); - return { id: `location:${locationId}`, - renderKey: toCardRenderKey(locationId, sortedLocationDevices), locationId, title: getLocationTitle(locationId, locationsById, sortedLocationDevices), - devices: sensorDevices, - deviceBindingsByLabel + sensors: sortedLocationDevices.map((device) => + buildDashboardSensorCardEntry(device, duplicateCounts, deviceTypeLookup) + ) } satisfies DashboardLocationSensorCard; }) .sort((left, right) => diff --git a/src/lib/components/dashboard/device-table.ts b/src/lib/components/dashboard/device-table.ts index aafb1f52..eb87d697 100644 --- a/src/lib/components/dashboard/device-table.ts +++ b/src/lib/components/dashboard/device-table.ts @@ -23,25 +23,14 @@ const NUMERIC_SORT_COLUMNS = new Set([ 'soil_humidity' ]); -function toTrimmedString(value: unknown): string { - return typeof value === 'string' ? value.trim() : ''; -} - function readGroupValue(value: unknown): string { - if (typeof value === 'string') { - return value.trim(); - } - - if (!value || typeof value !== 'object') { - return ''; - } + if (typeof value === 'string') return value.trim(); + if (!value || typeof value !== 'object') return ''; const record = value as Record; for (const key of LOCATION_GROUP_VALUE_KEYS) { - const candidate = toTrimmedString(record[key]); - if (candidate) { - return candidate; - } + const candidate = typeof record[key] === 'string' ? (record[key] as string).trim() : ''; + if (candidate) return candidate; } return ''; diff --git a/src/lib/components/layout/AppPage.svelte b/src/lib/components/layout/AppPage.svelte index 567ea3b9..9848e3fd 100644 --- a/src/lib/components/layout/AppPage.svelte +++ b/src/lib/components/layout/AppPage.svelte @@ -13,7 +13,7 @@ md: '48rem', lg: '64rem', xl: '82rem', - full: '100%' + full: 'none' }; let { children, width = 'xl', class: className = '' }: Props = $props(); @@ -29,13 +29,23 @@ diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 22a6663c..84a0a4e6 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -80,8 +80,7 @@ function buildDerivedLocations(latestPrimaryData: DevicePrimaryDataDto[]): Locat for (const device of latestPrimaryData) { const locationId = Number(device.location_id); - const locationName = - typeof device.location_name === 'string' ? device.location_name.trim() : ''; + const locationName = device.location_name?.trim() ?? ''; if (!Number.isFinite(locationId) || locationId <= 0 || !locationName) { continue; @@ -266,6 +265,7 @@ export const load: PageServerLoad = async ({ locals, fetch, url }) => { return { authToken, - dashboard: loadDashboardPayload(apiServiceInstance) + // Keep all dashboard fetches within the lifetime of this load function. + dashboard: await loadDashboardPayload(apiServiceInstance) }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 5b0e1d78..1f297113 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,6 +1,7 @@ (mode = mode === 'hidden' ? 'open' : 'hidden')} > {#snippet logo()} - {#if mode === 'mini'} - -
    - CropWatch Logo - 𝘾𝙧𝙤𝙥𝙒𝙖𝙩𝙘𝙝® UI -
    - {/if} +
    + {m.app_name()} + CropWatch +
    {/snippet} {#snippet actions()} - - +
    +
    + + +
    +
    { if (event.id === 'logout') { @@ -74,15 +82,86 @@
    diff --git a/src/routes/Sidebar.svelte b/src/routes/Sidebar.svelte index d819093a..3847d3d5 100644 --- a/src/routes/Sidebar.svelte +++ b/src/routes/Sidebar.svelte @@ -1,12 +1,14 @@