From dc0ecbf6c08d55acb68a34efb730cb526665e078 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Sun, 28 Jun 2026 17:41:31 +0900 Subject: [PATCH 1/3] fixed issue where CropWatch device Sensirion certificates dont download. --- messages/en.json | 4 + messages/ja.json | 4 + .../[dev_eui]/SensorCertificatesCard.svelte | 152 ++++++++---------- .../[dev_eui]/settings/+page.server.ts | 3 + .../devices/[dev_eui]/settings/+page.svelte | 13 +- .../settings/device-settings.server.ts | 57 ++++--- .../[sensor_key]/+server.ts | 22 +-- 7 files changed, 123 insertions(+), 132 deletions(-) diff --git a/messages/en.json b/messages/en.json index 270f0f91..c4e30b77 100644 --- a/messages/en.json +++ b/messages/en.json @@ -715,10 +715,14 @@ "devices_save_note_success": "Note saved successfully.", "devices_sensor_certificate_note": "Downloads the individual ISO17025 PDF returned by Sensirion Libellus for this serial number.", "devices_sensor_certificate_requires_login": "You must be logged in to download sensor certificates.", + "devices_sensor_certificate_unsupported_device": "This device does not support Sensirion Libellus calibration certificates.", "devices_sensor_certificates_subtitle": "Download the Libellus PDF certificate for each configured sensor serial.", "devices_sensor_certificates_title": "Device Sensor Certificates", "devices_sensor_one": "Sensor 1", "devices_sensor_serial_chip": "Serial {serial}", + "devices_sensor_series_certificate_label": "SHT4x series certificate", + "devices_sensor_series_certificate_note": "Downloads the generic SHT4x-series ISO calibration certificate. This is not specific to a serial number.", + "devices_sensor_sht43_label": "SHT43", "devices_sensor_two": "Sensor 2", "devices_settings_page_title": "Device Settings | {devEui} | CropWatch", "devices_settings_subtitle": "Update the cw_device name and group fields.", diff --git a/messages/ja.json b/messages/ja.json index dfaf5fea..1ef1ef39 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -731,10 +731,14 @@ "devices_save_note_success": "メモを保存しました。", "devices_sensor_certificate_note": "このシリアル番号に対して Sensirion Libellus が返す個別の ISO17025 PDF をダウンロードします。", "devices_sensor_certificate_requires_login": "センサー証明書をダウンロードするにはログインが必要です。", + "devices_sensor_certificate_unsupported_device": "このデバイスは Sensirion Libellus の校正証明書に対応していません。", "devices_sensor_certificates_subtitle": "設定された各センサーシリアルの Libellus PDF 証明書をダウンロードします。", "devices_sensor_certificates_title": "デバイスセンサー証明書", "devices_sensor_one": "センサー 1", "devices_sensor_serial_chip": "シリアル {serial}", + "devices_sensor_series_certificate_label": "SHT4x シリーズ証明書", + "devices_sensor_series_certificate_note": "SHT4x シリーズ共通の ISO 校正証明書をダウンロードします。シリアル番号には依存しません。", + "devices_sensor_sht43_label": "SHT43", "devices_sensor_two": "センサー 2", "devices_settings_page_title": "デバイス設定 | {devEui} | CropWatch", "devices_settings_subtitle": "cw_device の name と group フィールドを更新します。", diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/SensorCertificatesCard.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/SensorCertificatesCard.svelte index ef0329af..49e9bb52 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/SensorCertificatesCard.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/SensorCertificatesCard.svelte @@ -6,7 +6,7 @@ import { CwButton, CwCard, CwChip, CwSeparator } from '@cropwatchdevelopment/cwui'; type SensorCertificateRow = { - key: 'sensor' | 'sensor2'; + key: 'sensor'; label: string; serial: string; product: string; @@ -24,101 +24,85 @@ let sensorOneCertificate = $derived( sensorCertificates.find((target) => target.key === 'sensor') ?? null ); - let sensorTwoCertificate = $derived( - sensorCertificates.find((target) => target.key === 'sensor2') ?? null - ); - let hasSensorCertificates = $derived(Boolean(sensorOneCertificate || sensorTwoCertificate)); - const sensorTwoCertificateDownloadPath = asset( + const seriesCertificateDownloadPath = asset( '/files/Sensirion_Humidity_Sensors_SHTxx_Calibration_Certification.pdf' ); - {#if !hasSensorCertificates} -

{m.devices_no_sensor_serial()}

- {:else} -
- {#if sensorOneCertificate} -
-
-
- - - {#if sensorOneCertificate.product} - - {/if} -
- -

{m.devices_sensor_certificate_note()}

- - {#if sensorOneCertificate.downloadDisabledReason} -

{sensorOneCertificate.downloadDisabledReason}

+
+ {#if sensorOneCertificate} +
+
+
+ + + {#if sensorOneCertificate.product} + {/if}
-
- - - -
-
- {/if} +

{m.devices_sensor_certificate_note()}

- {#if sensorOneCertificate && sensorTwoCertificate} - - {/if} + {#if sensorOneCertificate.downloadDisabledReason} +

{sensorOneCertificate.downloadDisabledReason}

+ {/if} +
- {#if sensorTwoCertificate} -
-
-
- - - {#if sensorTwoCertificate.product} - - {/if} -
+
+ + + +
+
-

{m.devices_sensor_certificate_note()}

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

{m.devices_sensor_series_certificate_note()}

+
+ +
+ + + +
- {/if} +
diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.server.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.server.ts index 354f0e80..448a3415 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.server.ts +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.server.ts @@ -7,6 +7,7 @@ import type { PageServerLoad } from './$types'; import { buildLocationOwnerIdentityMap, buildSensorCertificateRows, + deviceSupportsSensorCertificate, normalizeDeviceOwners, readDeviceFormValues, readDeviceOwnerPermissionValues, @@ -31,6 +32,7 @@ export const load: PageServerLoad = async ({ fetch, params, parent }) => { ttiName: '', deviceGroups: [] as string[], sensorCertificates: [] as SensorCertificateRow[], + supportsSensorCertificates: false, deviceOwners: [] as NormalizedDeviceOwner[] }; } @@ -57,6 +59,7 @@ export const load: PageServerLoad = async ({ fetch, params, parent }) => { deviceGroups, locations, sensorCertificates: buildSensorCertificateRows(device), + supportsSensorCertificates: deviceSupportsSensorCertificate(device), deviceOwners: normalizeDeviceOwners(device, locationOwnerIdentities) }; }; diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.svelte index 8a5d18ff..78706421 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.svelte @@ -30,6 +30,7 @@ let ttiName = $derived(data.ttiName ?? ''); let location_id = $derived(String(data.location_id ?? '')); let sensorCertificates = $derived(data.sensorCertificates ?? []); + let supportsSensorCertificates = $derived(data.supportsSensorCertificates ?? false); let actionForm = $derived((form ?? null) as FormPayload); let deviceForm = $derived(actionForm?.action === 'updateDevice' ? actionForm : null); @@ -206,11 +207,13 @@ - + {#if supportsSensorCertificates} + + {/if}
diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/device-settings.server.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/device-settings.server.ts index 881aeff8..04f5f8ab 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/device-settings.server.ts +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/device-settings.server.ts @@ -40,7 +40,7 @@ export type NormalizedDeviceOwner = { }; export type SensorCertificateRow = { - key: 'sensor' | 'sensor2'; + key: 'sensor'; label: string; serial: string; product: string; @@ -131,42 +131,41 @@ export function normalizeDeviceOwners( ); } +// CropWatch air sensors carry two chips: an SHT40 and an SHT43. Only the SHT43 is +// supported by the Sensirion Libellus calibration-certificate API, and its serial is +// stored in sensor2_serial. The SHT40 (sensor1_serial) has no individual certificate. +// This capability is exclusive to CropWatch-manufactured sensors. +export function deviceSupportsSensorCertificate(device: DeviceDto | null): boolean { + return str(device?.cw_device_type?.manufacturer) === 'CropWatch'; +} + +export function getSht43Serial(device: DeviceDto | null): string { + if (!device) return ''; + return str((device as Record).sensor2_serial); +} + export function buildSensorCertificateRows(device: DeviceDto | null): SensorCertificateRow[] { - if (!device) return []; + if (!deviceSupportsSensorCertificate(device)) return []; + + const serial = getSht43Serial(device); + if (!serial) return []; - const product = str(device.cw_device_type?.model); const hasApiToken = str(env.PRIVATE_LIBELLUS_API_TOKEN).length > 0; const hasBaseUrl = str(env.PRIVATE_LIBELLUS_BASE_URL).length > 0; - const record = device as Record; - const rows = [ + + return [ { key: 'sensor' as const, - label: m.devices_sensor_one(), - serial: str(record.sensor1_serial) || str(record.sensor_serial) - }, - { - key: 'sensor2' as const, - label: m.devices_sensor_two(), - serial: str(record.sensor2_serial) + label: m.devices_sensor_sht43_label(), + serial, + product: str(device?.cw_device_type?.model), + downloadDisabledReason: !hasApiToken + ? m.devices_libellus_api_token_missing() + : !hasBaseUrl + ? m.devices_libellus_base_url_missing() + : null } ]; - - return rows - .filter((row) => row.serial.length > 0) - .map((row) => ({ - ...row, - product, - downloadDisabledReason: - row.key !== 'sensor' - ? null - : !hasApiToken - ? m.devices_libellus_api_token_missing() - : !hasBaseUrl - ? m.devices_libellus_base_url_missing() - : !product - ? m.devices_libellus_product_name_missing() - : null - })); } export function readDeviceFormValues(formData: FormData): DeviceFormValues { diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/libellus-certificates/[sensor_key]/+server.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/libellus-certificates/[sensor_key]/+server.ts index dac1038f..3935256d 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/libellus-certificates/[sensor_key]/+server.ts +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/libellus-certificates/[sensor_key]/+server.ts @@ -1,24 +1,13 @@ import { env } from '$env/dynamic/private'; import { ApiService } from '$lib/api/api.service'; -import type { DeviceDto } from '$lib/api/api.dtos'; import { m } from '$lib/paraglide/messages.js'; +import { deviceSupportsSensorCertificate, getSht43Serial } from '../../device-settings.server'; import type { RequestHandler } from './$types'; function readString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function getSensorSerial(device: DeviceDto, sensorKey: string): string { - const record = device as Record; - return sensorKey === 'sensor2' - ? readString(record.sensor2_serial) - : readString(record.sensor1_serial) || readString(record.sensor_serial); -} - -function getProductName(device: DeviceDto): string { - return readString(device.cw_device_type?.model); -} - function getLibellusBaseUrl(): string { const baseUrl = readString(env.PRIVATE_LIBELLUS_BASE_URL); if (!baseUrl) return ''; @@ -31,6 +20,7 @@ async function readErrorMessage(response: Response): Promise { if (contentType.includes('application/json')) { const payload = (await response.json()) as Record; return ( + readString(payload.error) || readString(payload.detail) || readString(payload.message) || m.devices_libellus_request_failed() @@ -54,7 +44,7 @@ export const GET: RequestHandler = async ({ fetch, locals, params, url }) => { return new Response(m.devices_sensor_certificate_requires_login(), { status: 401 }); } - if (!devEui || (sensorKey !== 'sensor' && sensorKey !== 'sensor2')) { + if (!devEui || sensorKey !== 'sensor') { return new Response(m.devices_invalid_certificate_target(), { status: 400 }); } @@ -73,7 +63,11 @@ export const GET: RequestHandler = async ({ fetch, locals, params, url }) => { return new Response(m.devices_not_found(), { status: 404 }); } - const sensorSerial = getSensorSerial(device, sensorKey); + if (!deviceSupportsSensorCertificate(device)) { + return new Response(m.devices_sensor_certificate_unsupported_device(), { status: 400 }); + } + + const sensorSerial = getSht43Serial(device); if (!sensorSerial) { return new Response(m.devices_no_sensor_serial_configured(), { status: 404 }); } From d8760ab458336c8eb7dd3e55b4ff025b8d2a9b3e Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Sun, 28 Jun 2026 20:32:45 +0900 Subject: [PATCH 2/3] rule history looks much better and more readable now! --- docs/rule-trigger-history.md | 111 ++++++ messages/en.json | 7 + messages/ja.json | 7 + .../[dev_eui]/DeviceDashboardHeader.svelte | 5 +- src/routes/rules/ViewRuleAlertHistory.svelte | 353 +++++++++++++----- 5 files changed, 380 insertions(+), 103 deletions(-) create mode 100644 docs/rule-trigger-history.md diff --git a/docs/rule-trigger-history.md b/docs/rule-trigger-history.md new file mode 100644 index 00000000..1592fddd --- /dev/null +++ b/docs/rule-trigger-history.md @@ -0,0 +1,111 @@ +# Rule Trigger History + +The "View History" dialog on the Rules page shows, for one rule template, every time +that rule **triggered** on a device and when it later **reset**. It answers: "When did +this alarm fire, on which sensor, what value tripped it, and how long did it stay in +alarm?" + +Each row in the log is one **episode**: a trigger event paired with its matching reset +(or no reset yet, meaning the alarm is still active). A single rule template can watch +many devices, so episodes from different devices are interleaved in one chronological +list. + +## Where it lives + +| Layer | File | +|---|---| +| Dialog component | `src/routes/rules/ViewRuleAlertHistory.svelte` | +| Invoked from | `src/routes/rules/+page.svelte` (row action: ``) | +| Frontend API call | `ApiService.getRuleTemplateHistory(id)` in `src/lib/api/api.service.ts` | +| DTO | `RuleTriggerLogDto` in `src/lib/api/api.dtos.ts` | +| Backend handler | `RulesNewService.getHistory()` in `api/src/v1/rules-new/rules-new.service.ts` | +| Backend DTO | `api/src/v1/rules-new/dto/rule-trigger-log.dto.ts` | +| Source table | Postgres `cw_rule_trigger_log` | +| i18n keys | `rules_new_history_*` and `rules_new_view_history` in `messages/{en,ja}.json` | + +## Data model + +`cw_rule_trigger_log` (one row per episode), surfaced as `RuleTriggerLogDto`: + +| Field | Meaning | +|---|---| +| `id` | Episode id (used as the `{#each}` key) | +| `devEui` / `deviceName` | The device the rule fired on; `deviceName` resolved server-side, may be null | +| `templateId` | Owning rule template | +| `triggeredAt` / `triggeredValue` | When the condition was first met, and the reading that met it | +| `resetAt` / `resetValue` | When the condition cleared, and the reading at reset. **Null = still active** | +| `createdAt` | Row creation timestamp (not displayed) | + +**Active vs resolved is derived purely from `resetAt`:** `resetAt == null` ⇒ active. + +## Data flow + +1. The history icon button calls `openHistory()`, which opens the dialog and, on first + open per `templateId`, calls `loadHistory()`. (`loadedFor` guards against re-fetching + the same template; loading from the click handler avoids a load-triggering `$effect`.) +2. `getHistory()` on the backend: + - Reuses `findOne()` so a hidden/non-existent template returns 404, not an empty list. + - Restricts rows to devices the caller **can view** (`viewableDevices`), then resolves + `deviceName` from the managed-device list. + - Orders by `triggered_at DESC` and caps at **200 rows**. +3. The component renders the returned episodes. + +## The UI (redesign, 2026-06) + +The list is a vertical **timeline per episode**: + +- **Card left border** encodes overall status — red (`--cw-danger-500`) for active, + green (`--cw-success-500`) for resolved. +- **Card header**: device name + a status chip (`Active` danger / `Resolved` success). +- **Timeline body**: a two-marker vertical track — + - top dot = `Triggered` (time + tripping value), + - connector line carrying the **duration** label in the middle, + - bottom dot = `Reset` (time + reset value) for resolved, or `Still active` for active. +- **Duration**: resolved episodes show `Lasted ` using `formatDuration()`; active + episodes show `Active for` + a live-ticking ``. + `formatDuration()` deliberately mirrors CwDuration's compact format (`13h 30m`, + `2d 04h`, `45m 12s`) so static and live durations read identically. +- **Summary chips** above the list: a danger "N active now" chip (when any are active) + and a "N events" total. +- **Device filter**: a compact `CwDropdown` (shown only when the rule spans more than one + device) with an "All devices" option plus one per device, filtering `filteredEntries`. + +### Why it was changed + +The previous layout placed the trigger on the far left, the device name small in the +top-right corner, and the reset/"Still active" on the far right with large empty gaps. +Problems it had: the trigger→reset relationship and **how long the alarm lasted were not +shown**; interleaved devices were hard to track because the device label was de-emphasized +and there was no per-device filter; the content was flung to both edges because the dialog +**inherits `text-align: right`** from the table action cell it mounts in (`.rule-history` +now pins `text-align: left`); and it hard-coded hex color fallbacks (`#dc2626`, etc.), +violating the [CWUI-first style contract](./cwui-first-style.md). + +The redesign makes each episode a self-contained timeline, surfaces duration, adds the +per-device filter and an active-count summary, and uses only CWUI design tokens. + +## Conventions & gotchas + +- **No hard-coded colors.** All colors come from CWUI tokens (`--cw-danger-500`, + `--cw-success-500`, `--cw-text-muted`, `--cw-border-muted`, `--cw-bg-surface`, …). Local + CSS handles layout only, per the style contract. +- **i18n.** Every visible string is a `m.rules_new_history_*` key in both `en.json` and + `ja.json`. Add new strings to both. `{duration}` is pre-formatted; `{count}` is stringified. +- **200-row cap & ordering** are backend concerns (`getHistory`). The component does not + paginate; if history needs to grow beyond 200, add paging there first. +- **Value units.** `triggeredValue`/`resetValue` are raw numbers — the rule's metric/unit + is not part of the DTO, so values render unit-less. If units are wanted, plumb them + through `RuleTriggerLogDto` from the rule template. +- **Device visibility** is enforced server-side; the client trusts the returned set. + +## Verifying changes + +1. `pnpm check` (svelte-check) — must stay at 0 errors. +2. Run the Svelte MCP `svelte-autofixer` on the component until it reports no issues. +3. `pnpm dev`, open the Rules page, click the history icon on a multi-device rule + (e.g. a tunnel-freezer temperature rule). Confirm: active episodes show a live-ticking + "Active for" duration and a red border; resolved episodes show "Lasted …" and a green + border; the device filter narrows the list; the summary counts match. + +To exercise it against real data, query `cw_rule_trigger_log` for a template with a mix of +active and reset rows (templates with `reset_at IS NULL` rows have active episodes). diff --git a/messages/en.json b/messages/en.json index c4e30b77..f391548e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -400,6 +400,13 @@ "rules_new_history_loading": "Loading history…", "rules_new_history_empty": "This rule template has not triggered yet.", "rules_new_history_load_failed": "Unable to load rule history.", + "rules_new_history_status_active": "Active", + "rules_new_history_status_resolved": "Resolved", + "rules_new_history_duration_lasted": "Lasted {duration}", + "rules_new_history_duration_active": "Active for", + "rules_new_history_active_summary": "{count} active now", + "rules_new_history_total_events": "{count} events", + "rules_new_history_filter_all": "All devices", "reports_page_title": "Reports - CropWatch", "reports_weekly_reports": "Weekly Reports", "reports_for_device": "For Device", diff --git a/messages/ja.json b/messages/ja.json index 1ef1ef39..4bee817a 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -400,6 +400,13 @@ "rules_new_history_loading": "履歴を読み込んでいます…", "rules_new_history_empty": "このルールテンプレートはまだトリガーされていません。", "rules_new_history_load_failed": "ルール履歴を読み込めませんでした。", + "rules_new_history_status_active": "発生中", + "rules_new_history_status_resolved": "解消", + "rules_new_history_duration_lasted": "継続 {duration}", + "rules_new_history_duration_active": "発生から", + "rules_new_history_active_summary": "現在 {count} 件発生中", + "rules_new_history_total_events": "{count} 件のイベント", + "rules_new_history_filter_all": "すべて", "reports_page_title": "レポート - CropWatch", "reports_weekly_reports": "週次レポート", "reports_for_device": "対象デバイス", diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/DeviceDashboardHeader.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/DeviceDashboardHeader.svelte index 6f0c9675..b565d2a4 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/DeviceDashboardHeader.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/DeviceDashboardHeader.svelte @@ -7,7 +7,7 @@ import SETTINGS_ICON from '$lib/images/icons/settings.svg'; import { m } from '$lib/paraglide/messages.js'; import { canManage, isAdmin } from '$lib/constants/permissions'; - import { CwButton, CwCard, CwDuration, CwSpinner } from '@cropwatchdevelopment/cwui'; + import { CwButton, CwCard, CwCopy, CwDuration, CwSpinner } from '@cropwatchdevelopment/cwui'; import CsvExportDialog from './dialogs/csvExportDialog.svelte'; import CsvTrafficExportDialog from './dialogs/csvTrafficExportDialog.svelte'; import type { RangeSelection, TimeRangeOptions } from './device-detail'; @@ -65,6 +65,7 @@ elevated > {#snippet actions()} +

{m.display_last_updated()}: {#if lastUpdatedAt} @@ -73,6 +74,8 @@ {m.common_not_available()} {/if}

+

Dev-Eui: {devEui}

+
{/snippet}
diff --git a/src/routes/rules/ViewRuleAlertHistory.svelte b/src/routes/rules/ViewRuleAlertHistory.svelte index 1ba4ec86..5d2ddb72 100644 --- a/src/routes/rules/ViewRuleAlertHistory.svelte +++ b/src/routes/rules/ViewRuleAlertHistory.svelte @@ -3,8 +3,9 @@ import { readApiErrorMessage } from '$lib/api/api-error'; import { ApiService } from '$lib/api/api.service'; import { getAppContext } from '$lib/appContext.svelte'; + import { AppNotice } from '$lib/components/layout'; import Icon from '$lib/components/Icon.svelte'; - import { CwButton, CwDialog } from '@cropwatchdevelopment/cwui'; + import { CwButton, CwChip, CwDialog, CwDropdown, CwDuration } from '@cropwatchdevelopment/cwui'; import { m } from '$lib/paraglide/messages.js'; import HISTORY_ICON from '$lib/images/icons/history.svg'; @@ -22,16 +23,17 @@ let errorMessage = $state(null); let entries = $state([]); let loadedFor = $state(null); + let selectedDevice = $state(''); - $effect(() => { - if (open && !loading && loadedFor !== templateId) { - void loadHistory(); - } - }); + function openHistory() { + open = true; + if (!loading && loadedFor !== templateId) void loadHistory(); + } async function loadHistory() { loading = true; errorMessage = null; + selectedDevice = ''; try { const api = new ApiService({ authToken: app.accessToken }); entries = await api.getRuleTemplateHistory(templateId); @@ -43,6 +45,26 @@ } } + // Distinct devices in the log, so a multi-device rule can be filtered to one sensor. + let devices = $derived.by(() => { + const list: { devEui: string; name: string }[] = []; + for (const entry of entries) { + if (list.some((device) => device.devEui === entry.devEui)) continue; + list.push({ devEui: entry.devEui, name: entry.deviceName ?? entry.devEui }); + } + return list.sort((left, right) => left.name.localeCompare(right.name)); + }); + + let deviceOptions = $derived([ + { label: m.rules_new_history_filter_all(), value: '' }, + ...devices.map((device) => ({ label: device.name, value: device.devEui })) + ]); + + let activeCount = $derived(entries.filter((entry) => !entry.resetAt).length); + let filteredEntries = $derived( + selectedDevice ? entries.filter((entry) => entry.devEui === selectedDevice) : entries + ); + const timestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' @@ -57,61 +79,132 @@ function formatValue(value: number | null): string | null { return value === null || value === undefined ? null : String(value); } + + // Mirrors CwDuration's compact format so resolved (static) spans match the live + // CwDuration shown for ongoing alarms. + function formatDuration(ms: number): string { + const totalSec = Math.floor(ms / 1000); + const totalMin = Math.floor(totalSec / 60); + const totalHr = Math.floor(totalMin / 60); + const days = Math.floor(totalHr / 24); + const pad = (n: number) => n.toString().padStart(2, '0'); + if (totalSec < 60) return `${totalSec}s`; + if (totalMin < 60) return `${totalMin}m ${pad(totalSec % 60)}s`; + if (totalHr < 24) return `${totalHr}h ${pad(totalMin % 60)}m`; + return `${days}d ${pad(totalHr % 24)}h`; + } - (open = true)}> + -

{m.rules_new_history_subtitle({ name: ruleName })}

- - {#if loading} -

{m.rules_new_history_loading()}

- {:else if errorMessage} -

{errorMessage}

- {:else if entries.length === 0} -

{m.rules_new_history_empty()}

- {:else} -
    - {#each entries as entry (entry.id)} - {@const triggeredValue = formatValue(entry.triggeredValue)} - {@const resetValue = formatValue(entry.resetValue)} -
  • - {entry.deviceName ?? entry.devEui} -
    -
    - {m.rules_new_history_triggered()} - {formatTimestamp(entry.triggeredAt)} - {#if triggeredValue !== null} - - {m.rules_new_history_value({ value: triggeredValue })} - - {/if} +
    +

    {m.rules_new_history_subtitle({ name: ruleName })}

    + + {#if loading} +

    {m.rules_new_history_loading()}

    + {:else if errorMessage} + +

    {errorMessage}

    +
    + {:else if entries.length === 0} + +

    {m.rules_new_history_empty()}

    +
    + {:else} +
    +
    + {#if activeCount > 0} + + {/if} + +
    + + {#if devices.length > 1} +
    + +
    + {/if} +
    + +
      + {#each filteredEntries as entry (entry.id)} + {@const isActive = !entry.resetAt} + {@const triggeredValue = formatValue(entry.triggeredValue)} + {@const resetValue = formatValue(entry.resetValue)} + {@const durationMs = + entry.triggeredAt && entry.resetAt + ? new Date(entry.resetAt).getTime() - new Date(entry.triggeredAt).getTime() + : null} +
    • +
      + {entry.deviceName ?? entry.devEui} +
      - - {#if entry.resetAt} -
      - {m.rules_new_history_reset()} - {formatTimestamp(entry.resetAt)} - {#if resetValue !== null} - - {m.rules_new_history_value({ value: resetValue })} - + +
      + + {m.rules_new_history_triggered()} + {formatTimestamp(entry.triggeredAt)} + + {#if triggeredValue !== null}{m.rules_new_history_value({ + value: triggeredValue + })}{/if} + +
      + +

      + {#if isActive} + {m.rules_new_history_duration_active()} + {#if entry.triggeredAt} + {/if} -

      - {:else} -
      - - {m.rules_new_history_still_active()} + {:else if durationMs !== null} + {m.rules_new_history_duration_lasted({ duration: formatDuration(durationMs) })} + {/if} +

      + +
      + + {#if isActive} + {m.rules_new_history_still_active()} + {:else} + {m.rules_new_history_reset()} + {formatTimestamp(entry.resetAt)} + + {#if resetValue !== null}{m.rules_new_history_value({ value: resetValue })}{/if} -
      - {/if} -
      -
    • - {/each} -
    - {/if} + {/if} +
    +
  • + {/each} +
+ {/if} +
{#snippet actions()}
@@ -123,19 +216,41 @@ diff --git a/src/routes/auth/create-account/+page.svelte b/src/routes/auth/create-account/+page.svelte index 1c926bd4..526c472c 100644 --- a/src/routes/auth/create-account/+page.svelte +++ b/src/routes/auth/create-account/+page.svelte @@ -92,6 +92,7 @@

{m.auth_or_prefix()} navigateWithRedirect(event, '/auth/login')} @@ -101,6 +102,7 @@

{ @@ -152,6 +154,7 @@ navigateWithRedirect(event, '/auth/login')} diff --git a/src/routes/auth/create-account/CreateAccountConsentFields.svelte b/src/routes/auth/create-account/CreateAccountConsentFields.svelte index c946db06..d61fd1d5 100644 --- a/src/routes/auth/create-account/CreateAccountConsentFields.svelte +++ b/src/routes/auth/create-account/CreateAccountConsentFields.svelte @@ -28,6 +28,7 @@ {m.auth_agree_to()} {m.auth_password_label_required()} {m.auth_confirm_password_label_required()}

{m.auth_check_email_not_there()}

-
{m.auth_check_email_contact_support()}{m.auth_check_email_contact_support()}
{m.auth_forgot_password_sent_body()}

navigateWithRedirect(event, '/auth/login')} @@ -68,6 +69,7 @@

{m.auth_forgot_password_subtitle()}

{m.auth_email_label()}
navigateWithRedirect(event, '/auth/login')} @@ -140,6 +145,7 @@ navigateWithRedirect(event, '/auth/create-account')} diff --git a/src/routes/auth/login/+page.svelte b/src/routes/auth/login/+page.svelte index 95cf5727..eafcc649 100644 --- a/src/routes/auth/login/+page.svelte +++ b/src/routes/auth/login/+page.svelte @@ -61,6 +61,7 @@

{m.auth_login_subtitle()}

{ @@ -111,6 +112,7 @@
{m.auth_update_password_subtitle()}

{ @@ -59,6 +60,7 @@
+ {m.auth_back_to_login()} diff --git a/src/routes/gateways/+page.svelte b/src/routes/gateways/+page.svelte index 510f1248..6c260113 100644 --- a/src/routes/gateways/+page.svelte +++ b/src/routes/gateways/+page.svelte @@ -46,12 +46,12 @@ - goto(backHref(page.url, resolve('/')))}> + goto(backHref(page.url, resolve('/')))}> ← {m.action_back_to_dashboard()} - + {#snippet cell(row: GatewayTableRow, col: CwColumnDef, defaultValue: string)} {#if col.key === 'is_online'} {#if row.is_online} diff --git a/src/routes/layout.css b/src/routes/layout.css index 78b2d806..49796e43 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -100,4 +100,4 @@ body { max-width: calc(100vw - var(--app-shell-padding-inline-start) - var(--app-shell-padding-inline-end)); box-shadow: var(--cw-shadow-lg); } -} +} \ No newline at end of file diff --git a/src/routes/locations/+page.svelte b/src/routes/locations/+page.svelte index fa4b0a17..2866de96 100644 --- a/src/routes/locations/+page.svelte +++ b/src/routes/locations/+page.svelte @@ -74,12 +74,12 @@ - goto(backHref(page.url, resolve('/')))}> + goto(backHref(page.url, resolve('/')))}> ← {m.action_back_to_dashboard()} - {#snippet toolbarActions()} - goto(resolve('/locations/create'))}> + goto(resolve('/locations/create'))}> {/snippet} {#snippet rowActions(row: LocationDto)} - handleViewLocation(row)}> + handleViewLocation(row)}> {/snippet} diff --git a/src/routes/locations/[location_id]/+page.svelte b/src/routes/locations/[location_id]/+page.svelte index 8f0bfb41..676759fd 100644 --- a/src/routes/locations/[location_id]/+page.svelte +++ b/src/routes/locations/[location_id]/+page.svelte @@ -182,12 +182,12 @@ - goto(backHref(page.url, resolve('/')))}> + goto(backHref(page.url, resolve('/')))}> ← {m.action_back_to_dashboard()} - {#if selectedLocationId} goto(`/locations/${encodeURIComponent(+selectedLocationId)}/devices/create`)} @@ -208,6 +209,7 @@ {m.devices_add_device()} goto(`/locations/${encodeURIComponent(+selectedLocationId)}/settings`)} @@ -234,11 +236,12 @@ {#snippet rowActions(row: LocationDeviceRow)}
- handleViewDevice(row)}> + handleViewDevice(row)}> {#if data.hasSettings} { 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 78068e04..9c06baf0 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte @@ -443,7 +443,7 @@ {m.devices_error_status_detail({ status: deviceErrorStatus })}

{#snippet actions()} - (errorDialogOpen = false)}> + (errorDialogOpen = false)}> {m.action_close()} {/snippet} diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/DeviceDashboardHeader.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/DeviceDashboardHeader.svelte index b565d2a4..06f8f7a4 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/DeviceDashboardHeader.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/DeviceDashboardHeader.svelte @@ -55,7 +55,7 @@ } - + ← {m.action_back()} @@ -83,6 +83,7 @@ {#if !isTrafficDevice} {#each rangeOptions as range (range.value)}
- - - + + +

{row.name} ({row.email})

@@ -130,6 +131,7 @@
- + diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/NotesReviewDialog.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/NotesReviewDialog.svelte index d544a2eb..967d99b0 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/NotesReviewDialog.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/NotesReviewDialog.svelte @@ -203,7 +203,7 @@ }; - + {m.display_view_notes()} @@ -212,6 +212,7 @@
{entry.note}

- Delete + Delete
{/each} @@ -291,7 +293,7 @@
{#snippet actions()} - (open = false)} variant="secondary"> + (open = false)} variant="secondary"> {m.action_close()} {/snippet} diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvExportDialog.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvExportDialog.svelte index 68cb67ac..4f0ffa43 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvExportDialog.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvExportDialog.svelte @@ -182,6 +182,7 @@ {#snippet actions()} - Cancel - + Cancel + {m.action_download()} CSV {/snippet} diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvTrafficExportDialog.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvTrafficExportDialog.svelte index 2a211e57..fbec9c7b 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvTrafficExportDialog.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvTrafficExportDialog.svelte @@ -103,6 +103,7 @@
{#snippet actions()} - + {m.action_cancel()} - + {m.action_download()} CSV diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.svelte index 78706421..46423f60 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.svelte @@ -74,6 +74,7 @@
@@ -87,6 +88,7 @@ >
- + {#if deviceGroupError}

{deviceGroupError}

{/if} @@ -159,6 +163,7 @@
- + {#if ttiNameError}

{ttiNameError}

{/if} @@ -175,6 +180,7 @@
- + {#if locationError}

{locationError}

{/if} @@ -196,6 +202,7 @@
@@ -136,6 +137,7 @@ { submitting = true; @@ -169,11 +171,12 @@ {/if} - +
- +
@@ -224,6 +231,7 @@
@@ -262,7 +272,7 @@ > {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 a3a29dd5..441ff1a2 100644 --- a/src/routes/locations/[location_id]/settings/+page.svelte +++ b/src/routes/locations/[location_id]/settings/+page.svelte @@ -17,7 +17,7 @@ goto(`/locations/${data.locationId}`)} diff --git a/src/routes/locations/[location_id]/settings/DeletePermissionDialog.svelte b/src/routes/locations/[location_id]/settings/DeletePermissionDialog.svelte index f8c4f695..a77c4b0e 100644 --- a/src/routes/locations/[location_id]/settings/DeletePermissionDialog.svelte +++ b/src/routes/locations/[location_id]/settings/DeletePermissionDialog.svelte @@ -77,11 +77,13 @@

{m.locations_confirm_delete_permission_body({ email: selectedRow?.email ?? '' })}

{#snippet actions()} (openDeletePermissionDialog = false)}>{m.action_cancel()} {#key permissionsKey} - {#if editingPermissionId === row.id} { savePermissionLevelUpdate(row); }}>{m.action_save_changes()} { selectedRow = row; @@ -110,6 +113,7 @@ }}>{m.action_delete()} { selectedRow = null; @@ -118,6 +122,7 @@ > {:else} { editingPermissionId = row.id; diff --git a/src/routes/locations/[location_id]/settings/LocationPermissions.svelte b/src/routes/locations/[location_id]/settings/LocationPermissions.svelte index 20bd3280..ec8899d6 100644 --- a/src/routes/locations/[location_id]/settings/LocationPermissions.svelte +++ b/src/routes/locations/[location_id]/settings/LocationPermissions.svelte @@ -52,6 +52,7 @@ elevated > { @@ -78,6 +79,7 @@ class="permissions-form" > ({ @@ -96,14 +99,16 @@ /> (applyToAllDevices = checked)} /> - +
{ @@ -70,6 +71,7 @@ }} > - +
form)(); let submitting = $state(false); - let name = $state(form?.name ?? ''); - let description = $state(form?.description ?? ''); - let group = $state(form?.group ?? ''); - let lat = $state(form?.lat ?? ''); - let long = $state(form?.long ?? ''); + let name = $state(initial?.name ?? ''); + let description = $state(initial?.description ?? ''); + let group = $state(initial?.group ?? ''); + let lat = $state(initial?.lat ?? ''); + let long = $state(initial?.long ?? ''); - goto(resolve('/locations'))}> + goto(resolve('/locations'))}> ← {m.action_back()} { submitting = true; @@ -71,6 +73,7 @@ {/if} - goto(resolve('/locations'))}> + goto(resolve('/locations'))}> {m.action_cancel()} - + {m.locations_create_submit()} diff --git a/src/routes/offline/+page.svelte b/src/routes/offline/+page.svelte index 4605234d..6f88c029 100644 --- a/src/routes/offline/+page.svelte +++ b/src/routes/offline/+page.svelte @@ -15,7 +15,7 @@

{m.offline_eyebrow()}

{m.offline_heading()}

{m.offline_body()}

- +
diff --git a/src/routes/reports/+page.svelte b/src/routes/reports/+page.svelte index ef724d8b..72c2dd2b 100644 --- a/src/routes/reports/+page.svelte +++ b/src/routes/reports/+page.svelte @@ -168,13 +168,14 @@ - goto(backHref(page.url, resolve('/')))}> + goto(backHref(page.url, resolve('/')))}> ← {m.action_back_to_dashboard()} {#key tableKey} goto(resolve('/reports/edit/[id]', { id: String(row.id) }))} @@ -219,7 +221,7 @@ {/snippet} {#snippet toolbarActions()} - goto(resolve('/reports/create'))}> + goto(resolve('/reports/create'))}> {/snippet} diff --git a/src/routes/reports/DeleteReportTemplateDialog.svelte b/src/routes/reports/DeleteReportTemplateDialog.svelte index cb2e3300..22bedab8 100644 --- a/src/routes/reports/DeleteReportTemplateDialog.svelte +++ b/src/routes/reports/DeleteReportTemplateDialog.svelte @@ -48,7 +48,7 @@ } - (open = true)}> + (open = true)}> @@ -57,10 +57,11 @@ {#snippet actions()}
- (open = false)}> + (open = false)}> {m.action_cancel()}
(cadence.end_of_day = checked)} /> (cadence.end_of_week = checked)} /> - (cadence.is_active = checked)} - />
diff --git a/src/routes/reports/ReportProcessingSchedulesSection.svelte b/src/routes/reports/ReportProcessingSchedulesSection.svelte index 962aa915..40b6dbdc 100644 --- a/src/routes/reports/ReportProcessingSchedulesSection.svelte +++ b/src/routes/reports/ReportProcessingSchedulesSection.svelte @@ -21,15 +21,18 @@ onAdd, onRemove }: Props = $props(); - - let open = $state(schedules.length > 0); - + -
-

{m.reports_schedule_card_copy()}

- +

{m.reports_schedule_card_description()}

+ + +

{m.reports_schedule_card_warning()}

+
+ +
+
@@ -46,23 +49,26 @@

{m.reports_schedule_entry_heading({ index: String(index + 1) })}

- onRemove(schedule.key)}> + onRemove(schedule.key)}> {m.action_remove()}
(schedule.crosses_midnight = checked)} />

{m.reports_create_recipients_copy()}

- +
@@ -45,6 +45,7 @@
{#if recipients.length > 1}
{:else} - goto(resolve('/reports'))} disabled={submitting}> + goto(resolve('/reports'))} disabled={submitting}> {m.action_cancel()} - (open = true)}> + (open = true)}> @@ -120,13 +120,14 @@
  • {group.deviceName ?? group.devEui}
      - {#each group.items as item (item.id ?? item.name)} + {#each group.items as item, itemIndex (item.id ?? item.name)}
    • {item.name} {formatTimestamp(item.createdAt)}
      - (open = false)}> + (open = false)}> {m.action_close()}
  • diff --git a/src/routes/reports/create/+page.svelte b/src/routes/reports/create/+page.svelte index 22ae633b..0fdc6961 100644 --- a/src/routes/reports/create/+page.svelte +++ b/src/routes/reports/create/+page.svelte @@ -15,7 +15,7 @@ - goto(resolve('/reports'))}> + goto(resolve('/reports'))}> ← {m.action_back()} diff --git a/src/routes/reports/edit/[id]/+page.svelte b/src/routes/reports/edit/[id]/+page.svelte index d877a720..ec2ff37a 100644 --- a/src/routes/reports/edit/[id]/+page.svelte +++ b/src/routes/reports/edit/[id]/+page.svelte @@ -16,7 +16,7 @@ - goto(resolve('/reports'))}> + goto(resolve('/reports'))}> ← {m.action_back()} diff --git a/src/routes/reports/report-template-form.ts b/src/routes/reports/report-template-form.ts index bed6a298..3a70ae0c 100644 --- a/src/routes/reports/report-template-form.ts +++ b/src/routes/reports/report-template-form.ts @@ -179,7 +179,11 @@ export function createReportTemplateDraftFactory(nextRowKey: (prefix: string) => end_of_week: firstSchedule.endOfWeek, end_of_month: firstSchedule.endOfMonth, utc_offset: String(firstSchedule.utcOffset ?? 0), - is_active: firstSchedule.isActive, + // Schedule-level active flag is no longer surfaced in the UI (it was + // redundant with the template-level "Active template" toggle, which is + // the single pause control). Keep the schedule active; pausing is done + // at the template level. + is_active: true, key: nextRowKey('cadence') } : createEmptyCadence(); diff --git a/src/routes/rules/+page.svelte b/src/routes/rules/+page.svelte index af1c3a66..84b9cd7b 100644 --- a/src/routes/rules/+page.svelte +++ b/src/routes/rules/+page.svelte @@ -42,7 +42,7 @@ // { key: 'statusLabel', header: m.rules_new_status(), sortable: true }, { key: 'assignmentSummary', header: m.rules_new_assigned_devices() }, // { key: 'locationName', header: m.nav_locations(), sortable: true }, - { key: 'criteriaSummary', header: m.rules_conditions() }, + { key: 'criteriaSummary', header: m.rules_conditions(), align: 'left' }, { key: 'actionSummary', header: m.rules_new_actions() }, // { key: 'triggeredCount', header: m.rules_new_triggered_devices(), sortable: true }, { key: 'createdAtLabel', header: m.common_created(), sortable: true } @@ -178,13 +178,14 @@ - goto(backHref(page.url, resolve('/')))}> + goto(backHref(page.url, resolve('/')))}> ← {m.action_back_to_dashboard()} {#key tableKey} + {:else if col.key === 'criteriaSummary'} + + {row.criteriaSummary} + {:else} {defaultValue} {/if} @@ -220,6 +225,7 @@ {#snippet rowActions(row: RuleTemplateRow)}
    goto(resolve('/rules/edit/[id]', { id: String(row.id) }))} @@ -236,7 +242,7 @@ {/snippet} {#snippet toolbarActions()} - goto(resolve('/rules/create'))}> + goto(resolve('/rules/create'))}> {/snippet} @@ -246,6 +252,14 @@ diff --git a/src/routes/rules/ViewRuleAlertHistory.svelte b/src/routes/rules/ViewRuleAlertHistory.svelte index 5d2ddb72..b94bec8e 100644 --- a/src/routes/rules/ViewRuleAlertHistory.svelte +++ b/src/routes/rules/ViewRuleAlertHistory.svelte @@ -95,7 +95,7 @@ } - + @@ -134,7 +134,7 @@ {#if devices.length > 1}
    - +
    {/if}
    @@ -208,7 +208,7 @@ {#snippet actions()}
    - (open = false)}> + (open = false)}> {m.action_close()}
    diff --git a/src/routes/rules/create/+page.svelte b/src/routes/rules/create/+page.svelte index 8304f78d..8901e4bc 100644 --- a/src/routes/rules/create/+page.svelte +++ b/src/routes/rules/create/+page.svelte @@ -15,7 +15,7 @@ - goto(resolve('/rules'))}> + goto(resolve('/rules'))}> ← {m.action_back()} diff --git a/src/routes/rules/edit/[id]/+page.svelte b/src/routes/rules/edit/[id]/+page.svelte index 3aaa3141..d1f14ac8 100644 --- a/src/routes/rules/edit/[id]/+page.svelte +++ b/src/routes/rules/edit/[id]/+page.svelte @@ -16,7 +16,7 @@ - goto(resolve('/rules'))}> + goto(resolve('/rules'))}> ← {m.action_back()} diff --git a/src/routes/settings/+page.server.ts b/src/routes/settings/+page.server.ts index efe13805..98314705 100644 --- a/src/routes/settings/+page.server.ts +++ b/src/routes/settings/+page.server.ts @@ -1,5 +1,10 @@ +import { fail } from '@sveltejs/kit'; +import { ApiService, ApiServiceError } from '$lib/api/api.service'; +import type { UpdatePreferencesRequest } from '$lib/api/api.dtos'; +import { readApiErrorMessage } from '$lib/api/api-error'; +import { m } from '$lib/paraglide/messages.js'; import { getLocale } from '$lib/paraglide/runtime'; -import type { PageServerLoad } from './$types'; +import type { Actions, PageServerLoad } from './$types'; type Option = { label: string; @@ -12,12 +17,14 @@ type PreferenceDraft = { language: SupportedLocale; theme: 'dark' | 'light' | 'system'; temperatureUnit: string; + weightUnit: string; ecUnit: string; + waterDepthUnit: string; + timezone: string; soilMoistureUnit: string; rainfallUnit: string; windSpeedUnit: string; pressureUnit: string; - waterDepthUnit: string; co2Unit: string; distanceUnit: string; areaUnit: string; @@ -35,19 +42,65 @@ const languageOptions: Option[] = [ { label: 'English', value: 'en' } ]; +// Persisted (profile_preferences) — value sets must match the DB CHECK constraints. const temperatureUnitOptions: Option[] = [ - { label: 'Celsius (C)', value: 'celsius' }, - { label: 'Fahrenheit (F)', value: 'fahrenheit' }, + { label: 'Celsius (°C)', value: 'celsius' }, + { label: 'Fahrenheit (°F)', value: 'fahrenheit' }, { label: 'Kelvin (K)', value: 'kelvin' } ]; +const weightUnitOptions: Option[] = [ + { label: 'Kilograms (kg)', value: 'kg' }, + { label: 'Pounds (lb)', value: 'lb' } +]; + const ecUnitOptions: Option[] = [ - { label: 'uS/cm', value: 'us_cm' }, { label: 'mS/cm', value: 'ms_cm' }, - { label: 'dS/m', value: 'ds_m' }, - { label: 'dS/cm', value: 'ds_cm' } + { label: 'dS/cm', value: 'ds_cm' }, + { label: 'µS/cm', value: 'us_cm' } +]; + +const waterLevelUnitOptions: Option[] = [ + { label: 'Millimeters (mm)', value: 'mm' }, + { label: 'Centimeters (cm)', value: 'cm' }, + { label: 'Inches (in)', value: 'inch' }, + { label: 'Feet (ft)', value: 'foot' }, + { label: 'Meters (m)', value: 'meter' }, + { label: 'Yards (yd)', value: 'yard' } +]; + +// One representative major city per UTC offset (value is the IANA zone). +const timezoneOptions: Option[] = [ + { label: '(UTC-11:00) Pago Pago', value: 'Pacific/Pago_Pago' }, + { label: '(UTC-10:00) Honolulu', value: 'Pacific/Honolulu' }, + { label: '(UTC-09:00) Anchorage', value: 'America/Anchorage' }, + { label: '(UTC-08:00) Los Angeles', value: 'America/Los_Angeles' }, + { label: '(UTC-07:00) Denver', value: 'America/Denver' }, + { label: '(UTC-06:00) Mexico City', value: 'America/Mexico_City' }, + { label: '(UTC-05:00) New York', value: 'America/New_York' }, + { label: '(UTC-04:00) Santiago', value: 'America/Santiago' }, + { label: '(UTC-03:00) Sao Paulo', value: 'America/Sao_Paulo' }, + { label: '(UTC-01:00) Azores', value: 'Atlantic/Azores' }, + { label: '(UTC+00:00) London', value: 'Europe/London' }, + { label: '(UTC+01:00) Paris', value: 'Europe/Paris' }, + { label: '(UTC+02:00) Cairo', value: 'Africa/Cairo' }, + { label: '(UTC+03:00) Moscow', value: 'Europe/Moscow' }, + { label: '(UTC+03:30) Tehran', value: 'Asia/Tehran' }, + { label: '(UTC+04:00) Dubai', value: 'Asia/Dubai' }, + { label: '(UTC+05:00) Karachi', value: 'Asia/Karachi' }, + { label: '(UTC+05:30) Mumbai', value: 'Asia/Kolkata' }, + { label: '(UTC+05:45) Kathmandu', value: 'Asia/Kathmandu' }, + { label: '(UTC+06:00) Dhaka', value: 'Asia/Dhaka' }, + { label: '(UTC+07:00) Bangkok', value: 'Asia/Bangkok' }, + { label: '(UTC+08:00) Shanghai', value: 'Asia/Shanghai' }, + { label: '(UTC+09:00) Tokyo', value: 'Asia/Tokyo' }, + { label: '(UTC+09:30) Adelaide', value: 'Australia/Adelaide' }, + { label: '(UTC+10:00) Sydney', value: 'Australia/Sydney' }, + { label: '(UTC+11:00) Noumea', value: 'Pacific/Noumea' }, + { label: '(UTC+12:00) Auckland', value: 'Pacific/Auckland' } ]; +// Not yet persisted — displayed disabled until backing columns/consumers exist. const soilMoistureUnitOptions: Option[] = [ { label: 'VWC (%)', value: 'vwc_percent' }, { label: 'Relative saturation (%)', value: 'relative_percent' }, @@ -75,13 +128,6 @@ const pressureUnitOptions: Option[] = [ { label: 'PSI', value: 'psi' } ]; -const waterDepthUnitOptions: Option[] = [ - { label: 'Centimeters (cm)', value: 'cm' }, - { label: 'Meters (m)', value: 'm' }, - { label: 'Inches (in)', value: 'in' }, - { label: 'Feet (ft)', value: 'ft' } -]; - const co2UnitOptions: Option[] = [ { label: 'PPM', value: 'ppm' }, { label: 'mg/m3', value: 'mg_m3' } @@ -114,24 +160,20 @@ const decimalSeparatorOptions: Option[] = [ { label: 'Comma (1.234,56)', value: 'comma' } ]; -const readString = (value: unknown): string => { - if (typeof value !== 'string') { - return ''; - } - - return value.trim(); -}; +const readString = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); const createDefaultPreferences = (locale: string): PreferenceDraft => ({ language: locale.startsWith('en') ? 'en' : 'ja', theme: 'system', temperatureUnit: 'celsius', + weightUnit: 'kg', ecUnit: 'us_cm', + waterDepthUnit: 'cm', + timezone: '', soilMoistureUnit: 'vwc_percent', rainfallUnit: 'mm', windSpeedUnit: 'm_s', pressureUnit: 'hpa', - waterDepthUnit: 'cm', co2Unit: 'ppm', distanceUnit: 'km', areaUnit: 'hectares', @@ -144,23 +186,56 @@ const createDefaultPreferences = (locale: string): PreferenceDraft => ({ highlightAlertThresholds: true }); -export const load: PageServerLoad = async ({ locals }) => { - const session = locals.jwt ?? null; +export const load: PageServerLoad = async ({ parent, fetch }) => { + const { authToken, session } = await parent(); const locale = getLocale(); + const defaults = createDefaultPreferences(locale); + + let preferences = defaults; + if (authToken) { + try { + const api = new ApiService({ fetchFn: fetch, authToken }); + const prefs = await api.getPreferences(); + preferences = { + ...defaults, + theme: (prefs.theme as PreferenceDraft['theme']) ?? defaults.theme, + temperatureUnit: prefs.temperature_unit ?? defaults.temperatureUnit, + weightUnit: prefs.weight_unit ?? defaults.weightUnit, + ecUnit: prefs.ec_unit ?? defaults.ecUnit, + waterDepthUnit: prefs.water_level_unit ?? defaults.waterDepthUnit, + timezone: prefs.timezone ?? defaults.timezone, + distanceUnit: prefs.distance_unit ?? defaults.distanceUnit, + areaUnit: prefs.area_unit ?? defaults.areaUnit, + soilMoistureUnit: prefs.soil_moisture_unit ?? defaults.soilMoistureUnit, + pressureUnit: prefs.pressure_unit ?? defaults.pressureUnit, + rainfallUnit: prefs.rainfall_unit ?? defaults.rainfallUnit, + windSpeedUnit: prefs.wind_speed_unit ?? defaults.windSpeedUnit, + co2Unit: prefs.co2_unit ?? defaults.co2Unit, + dateFormat: prefs.date_format ?? defaults.dateFormat, + timeFormat: prefs.time_format ?? defaults.timeFormat + }; + } catch (error) { + // Falls back to defaults when the preferences table/endpoint is unavailable + // (e.g. before the 014 migration is applied). + console.error('Failed to load preferences:', error); + } + } return { email: readString(session?.email) || null, role: readString(session?.role) || null, - preferences: createDefaultPreferences(locale), + preferences, options: { language: languageOptions, temperature: temperatureUnitOptions, + weight: weightUnitOptions, ec: ecUnitOptions, + waterDepth: waterLevelUnitOptions, + timezone: timezoneOptions, soilMoisture: soilMoistureUnitOptions, rainfall: rainfallUnitOptions, windSpeed: windSpeedUnitOptions, pressure: pressureUnitOptions, - waterDepth: waterDepthUnitOptions, co2: co2UnitOptions, distance: distanceUnitOptions, area: areaUnitOptions, @@ -170,3 +245,41 @@ export const load: PageServerLoad = async ({ locals }) => { } }; }; + +export const actions: Actions = { + updatePreferences: async ({ request, locals, fetch }) => { + const authToken = locals.jwtString ?? null; + if (!authToken) return fail(401, { error: m.auth_not_authenticated() }); + + const formData = await request.formData(); + // The form only yields strings; the API validates them (@IsIn), so narrow + // to the request type at this boundary. + const payload = { + theme: readString(formData.get('theme')) || null, + temperature_unit: readString(formData.get('temperatureUnit')) || null, + weight_unit: readString(formData.get('weightUnit')) || null, + ec_unit: readString(formData.get('ecUnit')) || null, + water_level_unit: readString(formData.get('waterDepthUnit')) || null, + timezone: readString(formData.get('timezone')) || null, + distance_unit: readString(formData.get('distanceUnit')) || null, + area_unit: readString(formData.get('areaUnit')) || null, + soil_moisture_unit: readString(formData.get('soilMoistureUnit')) || null, + pressure_unit: readString(formData.get('pressureUnit')) || null, + rainfall_unit: readString(formData.get('rainfallUnit')) || null, + wind_speed_unit: readString(formData.get('windSpeedUnit')) || null, + co2_unit: readString(formData.get('co2Unit')) || null, + date_format: readString(formData.get('dateFormat')) || null, + time_format: readString(formData.get('timeFormat')) || null + } as UpdatePreferencesRequest; + + const api = new ApiService({ fetchFn: fetch, authToken }); + try { + await api.updatePreferences(payload); + return { success: true }; + } catch (err) { + const errorPayload = err instanceof ApiServiceError ? err.payload : err; + const status = err instanceof ApiServiceError ? err.status : 500; + return fail(status, { error: readApiErrorMessage(errorPayload, m.generic_error()) }); + } + } +}; diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index c692765d..3a50448b 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -1,4 +1,6 @@ @@ -86,43 +51,58 @@ {m.nav_settings()} - CropWatch - - event.preventDefault()}> + + { + saving = true; + return async ({ result }) => { + saving = false; + await applyAction(result); + if (result.type === 'success') { + toast.add({ tone: 'success', message: m.settings_saved() }); + await invalidateAll(); + } else if (result.type === 'failure' && typeof result.data?.error === 'string') { + toast.add({ tone: 'danger', message: result.data.error }); + } + }; + }} + > + + +
    - +
    + + - - -
    - -
    - -
    @@ -130,14 +110,14 @@
    -

    Theme mode

    +

    {m.settings_theme_label()}

    {themeLabel}

    @@ -145,99 +125,99 @@
    - -
    - -
    - +
    + + + + - - -
    - -
    - - -
    @@ -245,23 +225,28 @@
    - +
    - - + {#if preferences.timezone} + + {/if}
    - - Reset preview + + {m.settings_reset()} + + + {m.action_save_changes()} - Save settings