diff --git a/database.types.ts b/database.types.ts index 9bda790f..16bccb3b 100644 --- a/database.types.ts +++ b/database.types.ts @@ -1605,6 +1605,77 @@ export type Database = { }, ] } + profile_preferences: { + Row: { + area_unit: string | null + co2_unit: string | null + created_at: string + date_format: string | null + distance_unit: string | null + ec_unit: string | null + pressure_unit: string | null + rainfall_unit: string | null + soil_moisture_unit: string | null + temperature_unit: string | null + theme: string | null + time_format: string | null + timezone: string | null + updated_at: string + user_id: string + water_level_unit: string | null + weight_unit: string | null + wind_speed_unit: string | null + } + Insert: { + area_unit?: string | null + co2_unit?: string | null + created_at?: string + date_format?: string | null + distance_unit?: string | null + ec_unit?: string | null + pressure_unit?: string | null + rainfall_unit?: string | null + soil_moisture_unit?: string | null + temperature_unit?: string | null + theme?: string | null + time_format?: string | null + timezone?: string | null + updated_at?: string + user_id: string + water_level_unit?: string | null + weight_unit?: string | null + wind_speed_unit?: string | null + } + Update: { + area_unit?: string | null + co2_unit?: string | null + created_at?: string + date_format?: string | null + distance_unit?: string | null + ec_unit?: string | null + pressure_unit?: string | null + rainfall_unit?: string | null + soil_moisture_unit?: string | null + temperature_unit?: string | null + theme?: string | null + time_format?: string | null + timezone?: string | null + updated_at?: string + user_id?: string + water_level_unit?: string | null + weight_unit?: string | null + wind_speed_unit?: string | null + } + Relationships: [ + { + foreignKeyName: "profile_preferences_user_id_fkey" + columns: ["user_id"] + isOneToOne: true + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } profiles: { Row: { accepted_agreements: boolean 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 270f0f91..f34b805c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -106,6 +106,52 @@ "error_unexpected_title": "Unexpected Error", "error_unknown": "An unknown error occurred.", "generic_error": "An unexpected error occurred. Please try again.", + "profile_identity_title": "Profile", + "profile_identity_subtitle": "The account details CropWatch uses across reports, notifications, and shared views.", + "profile_username_label": "Username", + "profile_full_name_label": "Full name", + "profile_employer_label": "Employer", + "profile_website_label": "Website", + "profile_website_invalid": "Enter a valid domain or URL.", + "profile_phone_label": "Phone number", + "profile_email_title": "Email address", + "profile_email_subtitle": "Changing your email sends a confirmation link to the new address. The change applies once you confirm.", + "profile_email_pending": "Confirmation sent. Check your inbox to complete the change.", + "profile_email_label": "Email", + "profile_email_send_action": "Send confirmation", + "profile_email_locked": "CropWatch email addresses (cropwatch.io / cropwatch.co.jp) can't be changed here. Contact an administrator if you need to update it.", + "profile_email_invalid": "Enter a valid email address.", + "profile_saved": "Profile updated.", + "profile_email_sent": "Confirmation email sent.", + "settings_regional_title": "Regional", + "settings_regional_subtitle": "Language, timezone, and calendar notation used across the app.", + "settings_timezone_label": "Home timezone", + "settings_timezone_placeholder": "Select a timezone", + "settings_appearance_title": "Appearance", + "settings_appearance_subtitle": "Theme and spatial notation for the interface.", + "settings_theme_label": "Theme mode", + "settings_theme_dark": "Dark", + "settings_theme_light": "Light", + "settings_theme_system": "System", + "settings_units_title": "Measurement units", + "settings_units_subtitle": "Choose the units you expect when reading sensor data.", + "settings_temperature_label": "Temperature", + "settings_ec_label": "Electrical conductivity", + "settings_water_level_label": "Water level", + "settings_weight_label": "Weight", + "settings_date_format_label": "Date format", + "settings_time_format_label": "Time format", + "settings_distance_label": "Distance", + "settings_area_label": "Area", + "settings_soil_moisture_label": "Soil moisture", + "settings_pressure_label": "Pressure", + "settings_rainfall_label": "Rainfall", + "settings_wind_speed_label": "Wind speed", + "settings_co2_label": "CO₂", + "settings_units_pending_note": "Additional units below are coming soon and are not saved yet.", + "settings_actions_title": "Save preferences", + "settings_reset": "Reset", + "settings_saved": "Preferences saved.", "error_reset_and_login": "Reset & Sign In", "offline_page_title": "CropWatch Offline", "offline_page_description": "CropWatch is offline. Reconnect to load live device data, reports, and settings.", @@ -400,6 +446,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", @@ -557,9 +610,11 @@ "reports_create_operator_range": "is between", "reports_create_method_email": "Email", "reports_create_method_sms": "Text message", - "reports_schedule_card_title": "Data Processing Schedules", + "reports_schedule_card_title": "(ADVANCED) Data Processing Schedules", "reports_schedule_card_subtitle": "Configure what data to include or exclude based on time ranges.", "reports_schedule_card_copy": "Add one or more time windows to control which readings are included in this report.", + "reports_schedule_card_description": "Data processing schedules control which sensor readings are counted in this report, based on the day of the week and the time of day. Each schedule is a time window plus a rule that either includes or excludes the readings that fall inside it. For example, include only operating hours (09:00 to 17:00, Monday to Friday), or exclude overnight periods when equipment is deliberately powered down. With no schedules, every reading in the reporting period is used. Each window is evaluated in the timezone you set and can cross midnight.", + "reports_schedule_card_warning": "If you do not know what this is, please reach out to support before adding any settings here. Incorrect schedules can silently remove data from your reports.", "reports_schedule_empty": "No data processing schedules yet. Add one to filter report data by time.", "reports_schedule_entry_heading": "Schedule {index}", "reports_schedule_day_of_week": "Day of week", @@ -715,10 +770,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.", @@ -1160,8 +1219,9 @@ "reports_new_cadence_month_label": "End of month", "reports_new_cadence_month_description": "Generate a report at the end of each month.", "reports_new_cadence_utc_offset": "UTC offset (hours)", - "reports_new_cadence_active_label": "Active cadence", - "reports_new_cadence_active_description": "Disable to pause report generation without removing the schedule.", + "reports_new_cadence_active_label_on": "Report generation (ON)", + "reports_new_cadence_active_label_off": "Report generation (OFF)", + "reports_new_cadence_active_description": "Turn off to pause this report. You can turn it back on anytime.", "reports_new_created_success": "Report template \"{name}\" created successfully.", "reports_new_updated_success": "Report template \"{name}\" updated successfully.", "reports_new_deleted_success": "Report template \"{name}\" deleted successfully.", diff --git a/messages/ja.json b/messages/ja.json index dfaf5fea..932d725b 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -106,6 +106,52 @@ "error_unexpected_title": "予期しないエラー", "error_unknown": "不明なエラーが発生しました。", "generic_error": "予期しないエラーが発生しました。もう一度お試しください。", + "profile_identity_title": "プロフィール", + "profile_identity_subtitle": "レポート、通知、共有ビュー全体で使用されるアカウント情報です。", + "profile_username_label": "ユーザー名", + "profile_full_name_label": "氏名", + "profile_employer_label": "勤務先", + "profile_website_label": "ウェブサイト", + "profile_website_invalid": "有効なドメインまたはURLを入力してください。", + "profile_phone_label": "電話番号", + "profile_email_title": "メールアドレス", + "profile_email_subtitle": "メールアドレスを変更すると、新しいアドレスに確認リンクが送信されます。確認後に変更が適用されます。", + "profile_email_pending": "確認メールを送信しました。受信トレイを確認して変更を完了してください。", + "profile_email_label": "メール", + "profile_email_send_action": "確認メールを送信", + "profile_email_locked": "CropWatchのメールアドレス(cropwatch.io / cropwatch.co.jp)はここでは変更できません。変更が必要な場合は管理者にお問い合わせください。", + "profile_email_invalid": "有効なメールアドレスを入力してください。", + "profile_saved": "プロフィールを更新しました。", + "profile_email_sent": "確認メールを送信しました。", + "settings_regional_title": "地域設定", + "settings_regional_subtitle": "アプリ全体で使用される言語、タイムゾーン、カレンダー表記です。", + "settings_timezone_label": "ホームタイムゾーン", + "settings_timezone_placeholder": "タイムゾーンを選択", + "settings_appearance_title": "外観", + "settings_appearance_subtitle": "インターフェースのテーマと空間表記です。", + "settings_theme_label": "テーマモード", + "settings_theme_dark": "ダーク", + "settings_theme_light": "ライト", + "settings_theme_system": "システム", + "settings_units_title": "測定単位", + "settings_units_subtitle": "センサーデータを読む際に使用する単位を選択してください。", + "settings_temperature_label": "温度", + "settings_ec_label": "電気伝導率", + "settings_water_level_label": "水位", + "settings_weight_label": "重量", + "settings_date_format_label": "日付形式", + "settings_time_format_label": "時刻形式", + "settings_distance_label": "距離", + "settings_area_label": "面積", + "settings_soil_moisture_label": "土壌水分", + "settings_pressure_label": "気圧", + "settings_rainfall_label": "降水量", + "settings_wind_speed_label": "風速", + "settings_co2_label": "CO₂", + "settings_units_pending_note": "以下の追加単位は近日対応予定で、まだ保存されません。", + "settings_actions_title": "設定を保存", + "settings_reset": "リセット", + "settings_saved": "設定を保存しました。", "error_reset_and_login": "リセットしてサインイン", "offline_page_title": "CropWatch オフライン", "offline_page_description": "CropWatch はオフラインです。ライブのデバイスデータ、レポート、設定を読み込むには再接続してください。", @@ -400,6 +446,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": "対象デバイス", @@ -557,9 +610,11 @@ "reports_create_operator_range": "の間", "reports_create_method_email": "メール", "reports_create_method_sms": "テキストメッセージ", - "reports_schedule_card_title": "データ処理スケジュール", + "reports_schedule_card_title": "(上級者向け) データ処理スケジュール", "reports_schedule_card_subtitle": "時間範囲に基づいてレポートに含めるデータを設定します。", "reports_schedule_card_copy": "このレポートに含む計測値を制御するため、1つ以上の時間ウィンドウを追加してください。", + "reports_schedule_card_description": "データ処理スケジュールでは、曜日と時間帯に基づいて、このレポートで集計するセンサー計測値を制御します。各スケジュールは時間ウィンドウと、その範囲内の計測値を「含める」または「除外する」ルールで構成されます。例えば、営業時間(月曜〜金曜の9:00〜17:00)のみを含めたり、設備を意図的に停止する夜間を除外したりできます。スケジュールが無い場合は、対象期間のすべての計測値が使用されます。各ウィンドウは指定したタイムゾーンで評価され、深夜をまたぐこともできます。", + "reports_schedule_card_warning": "この機能がよく分からない場合は、ここで設定を追加する前にサポートへご連絡ください。設定を誤ると、レポートから気づかないうちにデータが除外されることがあります。", "reports_schedule_empty": "データ処理スケジュールはまだありません。追加するとレポートデータを時間でフィルタリングできます。", "reports_schedule_entry_heading": "スケジュール {index}", "reports_schedule_day_of_week": "曜日", @@ -731,10 +786,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 フィールドを更新します。", @@ -1160,8 +1219,9 @@ "reports_new_cadence_month_label": "月末", "reports_new_cadence_month_description": "毎月末にレポートを生成します。", "reports_new_cadence_utc_offset": "UTCオフセット(時間)", - "reports_new_cadence_active_label": "有効な頻度", - "reports_new_cadence_active_description": "スケジュールを削除せずにレポート生成を一時停止するには無効にします。", + "reports_new_cadence_active_label_on": "レポート生成 (ON)", + "reports_new_cadence_active_label_off": "レポート生成 (OFF)", + "reports_new_cadence_active_description": "オフにするとこのレポートを一時停止します。いつでもオンに戻せます。", "reports_new_created_success": "レポートテンプレート「{name}」を作成しました。", "reports_new_updated_success": "レポートテンプレート「{name}」を更新しました。", "reports_new_deleted_success": "レポートテンプレート「{name}」を削除しました。", diff --git a/package.json b/package.json index cad062fc..2a808523 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@fontsource/fira-mono": "^5.2.7", "@inlang/paraglide-js": "^2.19.0", "@neoconfetti/svelte": "^2.2.2", + "@opentelemetry/api": "^1.9.1", "@playwright/test": "^1.61.0", "@sveltejs/adapter-vercel": "^6.3.3", "@sveltejs/kit": "^2.65.2", @@ -60,7 +61,7 @@ } }, "dependencies": { - "@cropwatchdevelopment/cwui": "0.1.107", + "@cropwatchdevelopment/cwui": "0.1.110", "@supabase/supabase-js": "^2.108.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2853a51d..c6dc62a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,8 +13,8 @@ importers: .: dependencies: '@cropwatchdevelopment/cwui': - specifier: 0.1.107 - version: 0.1.107(svelte@5.56.3(@typescript-eslint/types@8.61.1)) + specifier: 0.1.110 + version: 0.1.110(svelte@5.56.3(@typescript-eslint/types@8.61.1)) '@supabase/supabase-js': specifier: ^2.108.2 version: 2.108.2 @@ -34,15 +34,18 @@ importers: '@neoconfetti/svelte': specifier: ^2.2.2 version: 2.2.2(svelte@5.56.3(@typescript-eslint/types@8.61.1)) + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 '@playwright/test': specifier: ^1.61.0 version: 1.61.0 '@sveltejs/adapter-vercel': specifier: ^6.3.3 - version: 6.3.3(@sveltejs/kit@2.65.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.1))(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)))(rollup@4.62.0) + version: 6.3.3(@sveltejs/kit@2.65.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.1))(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)))(rollup@4.62.0) '@sveltejs/kit': specifier: ^2.65.2 - version: 2.65.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.1))(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) + version: 2.65.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.1))(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) '@sveltejs/vite-plugin-svelte': specifier: ^6.2.4 version: 6.2.4(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) @@ -114,7 +117,7 @@ importers: version: 1.0.0(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@22.19.21)(@vitest/browser-playwright@4.1.9)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.19.21)(@vitest/browser-playwright@4.1.9)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) vitest-browser-svelte: specifier: ^2.1.1 version: 2.1.1(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vitest@4.1.9) @@ -124,8 +127,8 @@ packages: '@blazediff/core@1.9.1': resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} - '@cropwatchdevelopment/cwui@0.1.107': - resolution: {integrity: sha512-09HVLBZxM+Q4NBNfIgLEurWlt4NDnkV/NPwvLP3Bm0xWH+zUIVoC1MpRdn/2EsEAIYoOIz4tHzhVVIbfigsODA==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.107/f942b24e41a617c9a85ad5e27c0e9cefa45d7ae9} + '@cropwatchdevelopment/cwui@0.1.110': + resolution: {integrity: sha512-tH8fRf6yv0eYiKXusZEcB/XdO1QPBYF0E9OOPXq3xYIrhFyA4pDrMQX3SQBqNjx1+rFQX8spCxEeUSbJ81bCqg==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.110/365655baf3d097e75a2b7a3539c7c4f1369b7ca9} peerDependencies: svelte: ^5.0.0 @@ -412,6 +415,10 @@ packages: peerDependencies: svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@playwright/test@1.61.0': resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} engines: {node: '>=18'} @@ -1980,7 +1987,7 @@ snapshots: '@blazediff/core@1.9.1': {} - '@cropwatchdevelopment/cwui@0.1.107(svelte@5.56.3(@typescript-eslint/types@8.61.1))': + '@cropwatchdevelopment/cwui@0.1.110(svelte@5.56.3(@typescript-eslint/types@8.61.1))': dependencies: svelte: 5.56.3(@typescript-eslint/types@8.61.1) @@ -2218,6 +2225,8 @@ snapshots: dependencies: svelte: 5.56.3(@typescript-eslint/types@8.61.1) + '@opentelemetry/api@1.9.1': {} + '@playwright/test@1.61.0': dependencies: playwright: 1.61.0 @@ -2373,9 +2382,9 @@ snapshots: dependencies: acorn: 8.17.0 - '@sveltejs/adapter-vercel@6.3.3(@sveltejs/kit@2.65.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.1))(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)))(rollup@4.62.0)': + '@sveltejs/adapter-vercel@6.3.3(@sveltejs/kit@2.65.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.1))(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)))(rollup@4.62.0)': dependencies: - '@sveltejs/kit': 2.65.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.1))(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) + '@sveltejs/kit': 2.65.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.1))(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) '@vercel/nft': 1.10.2(rollup@4.62.0) esbuild: 0.28.1 transitivePeerDependencies: @@ -2383,7 +2392,7 @@ snapshots: - rollup - supports-color - '@sveltejs/kit@2.65.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.1))(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0))': + '@sveltejs/kit@2.65.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.1))(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.10(acorn@8.17.0) @@ -2401,6 +2410,7 @@ snapshots: svelte: 5.56.3(@typescript-eslint/types@8.61.1) vite: 7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0) optionalDependencies: + '@opentelemetry/api': 1.9.1 typescript: 5.9.3 '@sveltejs/load-config@0.1.1': {} @@ -2639,7 +2649,7 @@ snapshots: '@vitest/mocker': 4.1.9(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) playwright: 1.61.0 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@22.19.21)(@vitest/browser-playwright@4.1.9)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.19.21)(@vitest/browser-playwright@4.1.9)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) transitivePeerDependencies: - bufferutil - msw @@ -2655,7 +2665,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@22.19.21)(@vitest/browser-playwright@4.1.9)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.19.21)(@vitest/browser-playwright@4.1.9)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -3517,9 +3527,9 @@ snapshots: dependencies: '@testing-library/svelte-core': 1.0.0(svelte@5.56.3(@typescript-eslint/types@8.61.1)) svelte: 5.56.3(@typescript-eslint/types@8.61.1) - vitest: 4.1.9(@types/node@22.19.21)(@vitest/browser-playwright@4.1.9)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.19.21)(@vitest/browser-playwright@4.1.9)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) - vitest@4.1.9(@types/node@22.19.21)(@vitest/browser-playwright@4.1.9)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)): + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.19.21)(@vitest/browser-playwright@4.1.9)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)) @@ -3542,6 +3552,7 @@ snapshots: vite: 7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 22.19.21 '@vitest/browser-playwright': 4.1.9(playwright@1.61.0)(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0))(vitest@4.1.9) transitivePeerDependencies: diff --git a/src/lib/api/api.dtos.ts b/src/lib/api/api.dtos.ts index f41c8d86..77a397a9 100644 --- a/src/lib/api/api.dtos.ts +++ b/src/lib/api/api.dtos.ts @@ -18,6 +18,80 @@ export interface LoginResponse { [key: string]: unknown; } +export interface ProfileDto { + id: string; + email: string | null; + username: string | null; + full_name: string | null; + website: string | null; + employer: string | null; + phone_number: string | null; + avatar_url?: string | null; + [key: string]: unknown; +} + +export interface UpdateProfileRequest { + full_name?: string | null; + username?: string | null; + website?: string | null; + employer?: string | null; + phone_number?: string | null; +} + +export interface UpdateEmailRequest { + email: string; +} + +export interface EmailChangeResponse { + pending: boolean; + message?: string; +} + +export type ThemePreference = 'light' | 'dark' | 'system'; +export type TemperatureUnit = 'celsius' | 'fahrenheit' | 'kelvin'; +export type WeightUnit = 'kg' | 'lb'; +export type EcUnit = 'ms_cm' | 'ds_cm' | 'us_cm'; +export type WaterLevelUnit = 'cm' | 'mm' | 'inch' | 'foot' | 'meter' | 'yard'; + +export interface PreferencesDto { + user_id: string; + theme: ThemePreference | null; + temperature_unit: TemperatureUnit | null; + weight_unit: WeightUnit | null; + ec_unit: EcUnit | null; + water_level_unit: WaterLevelUnit | null; + timezone: string | null; + distance_unit: string | null; + area_unit: string | null; + soil_moisture_unit: string | null; + pressure_unit: string | null; + rainfall_unit: string | null; + wind_speed_unit: string | null; + co2_unit: string | null; + date_format: string | null; + time_format: string | null; + created_at?: string; + updated_at?: string; +} + +export interface UpdatePreferencesRequest { + theme?: ThemePreference | null; + temperature_unit?: TemperatureUnit | null; + weight_unit?: WeightUnit | null; + ec_unit?: EcUnit | null; + water_level_unit?: WaterLevelUnit | null; + timezone?: string | null; + distance_unit?: string | null; + area_unit?: string | null; + soil_moisture_unit?: string | null; + pressure_unit?: string | null; + rainfall_unit?: string | null; + wind_speed_unit?: string | null; + co2_unit?: string | null; + date_format?: string | null; + time_format?: string | null; +} + export type DeviceOwnerDto = CwDeviceOwner; export type DeviceTypeDto = CwDeviceType; diff --git a/src/lib/api/api.service.ts b/src/lib/api/api.service.ts index ef29b880..538d85ba 100644 --- a/src/lib/api/api.service.ts +++ b/src/lib/api/api.service.ts @@ -17,6 +17,12 @@ import type { LocationsQuery, LoginRequest, LoginResponse, + ProfileDto, + UpdateProfileRequest, + UpdateEmailRequest, + EmailChangeResponse, + PreferencesDto, + UpdatePreferencesRequest, PaginatedResponse, PaginationQuery, ReportTemplateDto, @@ -120,6 +126,8 @@ const UNAUTHORIZED_RETRY_DELAY_MS = 600; const AUTH_ENDPOINT = '/auth'; const AUTH_USER_PROFILE_ENDPOINT = '/auth/user-profile'; +const AUTH_EMAIL_ENDPOINT = '/auth/email'; +const AUTH_PREFERENCES_ENDPOINT = '/auth/preferences'; const AIR_ENDPOINT = '/air/{dev_eui}'; const DEVICES_ENDPOINT = '/devices'; const DEVICE_TYPES_ENDPOINT = '/devices/device-types'; @@ -484,8 +492,24 @@ export class ApiService { return this.request>(AUTH_ENDPOINT, { method: 'GET' }); } - public getUserProfile(): Promise> { - return this.request>(AUTH_USER_PROFILE_ENDPOINT, { method: 'GET' }); + public getUserProfile(): Promise { + return this.request(AUTH_USER_PROFILE_ENDPOINT, { method: 'GET' }); + } + + public updateProfile(payload: UpdateProfileRequest): Promise { + return this.request(AUTH_USER_PROFILE_ENDPOINT, { method: 'PATCH', body: payload }); + } + + public updateEmail(payload: UpdateEmailRequest): Promise { + return this.request(AUTH_EMAIL_ENDPOINT, { method: 'PATCH', body: payload }); + } + + public getPreferences(): Promise { + return this.request(AUTH_PREFERENCES_ENDPOINT, { method: 'GET' }); + } + + public updatePreferences(payload: UpdatePreferencesRequest): Promise { + return this.request(AUTH_PREFERENCES_ENDPOINT, { method: 'PATCH', body: payload }); } public login(payload: LoginRequest): Promise { diff --git a/src/lib/components/displays/TrafficDisplay/TrafficDisplay.svelte b/src/lib/components/displays/TrafficDisplay/TrafficDisplay.svelte index 071d10fc..345cf34a 100644 --- a/src/lib/components/displays/TrafficDisplay/TrafficDisplay.svelte +++ b/src/lib/components/displays/TrafficDisplay/TrafficDisplay.svelte @@ -1602,14 +1602,14 @@ color: inherit; } - [data-theme='dark'] .traffic-display__metric-row { + :global([data-theme='dark']) .traffic-display__metric-row { background: color-mix(in srgb, var(--cw-bg-surface-elevated) 80%, var(--cw-info-800) 20%); border-color: color-mix(in srgb, var(--cw-info-400) 20%, var(--cw-border-default)); color: var(--cw-text-primary); } - [data-theme='dark'] .traffic-display__weather-panel, - [data-theme='dark'] .traffic-display__calendar-weather-panel { + :global([data-theme='dark']) .traffic-display__weather-panel, + :global([data-theme='dark']) .traffic-display__calendar-weather-panel { background: radial-gradient( 130% 100% at 0% 0%, @@ -1628,7 +1628,7 @@ 0 8px 18px color-mix(in srgb, #050a16 36%, transparent); } - [data-theme='dark'] .traffic-display__calendar-traffic-panel { + :global([data-theme='dark']) .traffic-display__calendar-traffic-panel { background: radial-gradient( 120% 100% at 100% 0%, @@ -1647,8 +1647,8 @@ 0 8px 18px color-mix(in srgb, #050a16 38%, transparent); } - [data-theme='dark'] .traffic-display__weather-panel :global(.cw-chip), - [data-theme='dark'] .traffic-display__calendar-weather-panel :global(.cw-chip) { + :global([data-theme='dark']) .traffic-display__weather-panel :global(.cw-chip), + :global([data-theme='dark']) .traffic-display__calendar-weather-panel :global(.cw-chip) { box-shadow: inset 0 0 0 1px color-mix(in srgb, #ffffff 6%, transparent); } diff --git a/src/lib/components/layout/AppPage.svelte b/src/lib/components/layout/AppPage.svelte index 3b2f8b0b..00ddb935 100644 --- a/src/lib/components/layout/AppPage.svelte +++ b/src/lib/components/layout/AppPage.svelte @@ -6,6 +6,8 @@ interface Props { children?: Snippet; width?: AppPageWidth; + /** Vertical placement when the page content is shorter than the viewport. */ + align?: 'top' | 'center'; class?: string; } @@ -16,10 +18,13 @@ full: 'none' }; - let { children, width = 'xl', class: className = '' }: Props = $props(); + let { children, width = 'xl', align = 'top', class: className = '' }: Props = $props(); -
+
{#if children} {@render children()} @@ -47,6 +52,13 @@ gap: var(--cw-space-4); } + /* Center short content in the viewport; overflow-safe (tall content still + scrolls from the top because the auto margins collapse when space runs out). */ + .app-page--center .app-page__shell { + flex: 0 0 auto; + margin-block: auto; + } + @media (min-width: 640px) { .app-page { padding-block: var(--cw-space-2); diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte index 2d6f5eed..5957fc4d 100644 --- a/src/routes/+error.svelte +++ b/src/routes/+error.svelte @@ -92,9 +92,9 @@

{description}

- goto('/')}>{m.action_go_home()} - history.back()}>{m.action_go_back()} - {m.error_reset_and_login()} + goto('/')}>{m.action_go_home()} + history.back()}>{m.action_go_back()} + {m.error_reset_and_login()}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index d3728436..0183f382 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -114,6 +114,7 @@ class="hidden w-full flex-row gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-start md:flex" > -
- - -
- --> data)(); const toast = useCwToast(); let busy = $state(false); let buyQty = $state('1'); - let desiredSeats = $state(String(data.state.device.seats)); + let desiredSeats = $state(String(initial.state.device.seats)); let cancelOpen = $state(false); let assignOpen = $state(false); @@ -312,7 +313,7 @@ - goto(resolve('/'))}> + goto(resolve('/'))}> ← {m.action_back()} @@ -356,13 +357,14 @@ (cancelOpen = true)} disabled={busy || base.cancelAtPeriodEnd} > {m.billing_base_cancel()} - + {m.billing_base_manage()} @@ -371,7 +373,7 @@

{m.billing_base_none_notice()}

- + {basePriceLabel ? `${m.billing_base_subscribe()} — ${basePriceLabel}${m.billing_per_month()}` : m.billing_base_subscribe()} @@ -397,12 +399,14 @@ {#if hasDeviceSub}
- + {m.billing_buy_action()}
@@ -457,7 +462,7 @@ {#if license.devEui} {@const href = deviceHref(license.devEui)} {#if href} - {license.deviceName ?? license.devEui} + {license.deviceName ?? license.devEui} {:else} {license.deviceName ?? license.devEui} {/if} @@ -476,7 +481,7 @@ {@const lname = deviceLocationName(license.devEui)} {@const lhref = locationHref(license.devEui)} {#if lname && lhref} - {lname} + {lname} {:else if lname} {lname} {:else} @@ -488,6 +493,7 @@ {#if license.devEui} openMove(license.id)} @@ -496,6 +502,7 @@ {m.billing_move()} unassign(license.id)} @@ -505,6 +512,7 @@ {:else} openAssign(license.id)} @@ -513,6 +521,7 @@ {m.billing_assign()} openSeatCancel(license.id)} @@ -529,7 +538,7 @@ - {m.billing_portal()} + {m.billing_portal()}
@@ -538,10 +547,10 @@

{m.billing_cancel_body()}

{/snippet} {#snippet actions()} - (cancelOpen = false)} disabled={busy}> + (cancelOpen = false)} disabled={busy}> {m.action_cancel()} - + {m.billing_cancel_confirm()} {/snippet} @@ -558,6 +567,7 @@ {:else} (assignOpen = false)} disabled={busy}> + (assignOpen = false)} disabled={busy}> {m.action_cancel()} {m.billing_seat_cancel_body()}

{/snippet} {#snippet actions()} - (seatCancelOpen = false)} disabled={busy}> + (seatCancelOpen = false)} disabled={busy}> {m.billing_seat_cancel_keep()} - + {m.billing_seat_cancel_confirm()} {/snippet} diff --git a/src/routes/account/profile/+page.server.ts b/src/routes/account/profile/+page.server.ts index 1e4ad365..3170974c 100644 --- a/src/routes/account/profile/+page.server.ts +++ b/src/routes/account/profile/+page.server.ts @@ -1,37 +1,64 @@ -import type { Profile } from '$lib/interfaces/profile.interface'; -import type { PageServerLoad } from './$types'; +import { fail } from '@sveltejs/kit'; +import { ApiService, ApiServiceError } from '$lib/api/api.service'; +import { readApiErrorMessage } from '$lib/api/api-error'; +import { m } from '$lib/paraglide/messages.js'; +import type { Actions, PageServerLoad } from './$types'; -const readString = (value: unknown): string => { - if (typeof value !== 'string') { - return ''; - } +const readString = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); - return value.trim(); -}; +export const load: PageServerLoad = async ({ parent }) => { + const { profile, session } = await parent(); -const emailToUsername = (email: string): string => { - const [localPart = ''] = email.split('@'); - return localPart.trim(); + return { + profile: profile ?? null, + email: readString(profile?.email ?? session?.email) || null, + role: readString(session?.role) || null + }; }; -export const load: PageServerLoad = async ({ locals }) => { - const session = locals.jwt ?? null; - const email = readString(session?.email); - const fullName = readString(session?.user_metadata?.full_name); - const usernameFromMetadata = readString(session?.user_metadata?.name); - const phoneNumber = readString(session?.phone); - - const profile: Profile = { - username: usernameFromMetadata || emailToUsername(email), - full_name: fullName, - website: '', - employer: '', - phone_number: phoneNumber - }; +export const actions: Actions = { + updateProfile: async ({ request, locals, fetch }) => { + const authToken = locals.jwtString ?? null; + if (!authToken) return fail(401, { error: m.auth_not_authenticated() }); - return { - email: email || null, - role: readString(session?.role) || null, - profile - }; + const formData = await request.formData(); + const fields = { + username: readString(formData.get('username')), + full_name: readString(formData.get('full_name')), + employer: readString(formData.get('employer')), + website: readString(formData.get('website')), + phone_number: readString(formData.get('phone_number')) + }; + + const api = new ApiService({ fetchFn: fetch, authToken }); + try { + await api.updateProfile(fields); + return { success: true }; + } 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()), ...fields }); + } + }, + + updateEmail: async ({ request, locals, fetch }) => { + const authToken = locals.jwtString ?? null; + if (!authToken) return fail(401, { emailError: m.auth_not_authenticated() }); + + const formData = await request.formData(); + const email = readString(formData.get('email')); + if (!email || !email.includes('@')) { + return fail(400, { emailError: m.profile_email_invalid(), email }); + } + + const api = new ApiService({ fetchFn: fetch, authToken }); + try { + const result = await api.updateEmail({ email }); + return { emailPending: true, emailMessage: result.message ?? null, email }; + } catch (err) { + const payload = err instanceof ApiServiceError ? err.payload : err; + const status = err instanceof ApiServiceError ? err.status : 500; + return fail(status, { emailError: readApiErrorMessage(payload, m.generic_error()), email }); + } + } }; diff --git a/src/routes/account/profile/+page.svelte b/src/routes/account/profile/+page.svelte index d457743f..45ca8064 100644 --- a/src/routes/account/profile/+page.svelte +++ b/src/routes/account/profile/+page.svelte @@ -1,352 +1,206 @@ {m.nav_profile()} - CropWatch - -
- + goto(resolve('/'))}> + ← {m.action_back()} + + + + {#snippet actions()} + {#if data.role} + + {/if} + {/snippet} + +
{ + savingProfile = true; + return async ({ result }) => { + savingProfile = false; + if (result.type === 'success') { + toast.add({ tone: 'success', message: m.profile_saved() }); + await goto(resolve('/account/profile'), { invalidateAll: true }); + return; + } + await applyAction(result); + if (result.type === 'failure' && typeof result.data?.error === 'string') { + toast.add({ tone: 'danger', message: result.data.error }); + } + }; + }} > - {#snippet actions()} -
- {#if data.email} - - {/if} - {#if data.role} - - {/if} + + {#if form?.error} + +

{form.error}

+
+ {/if} + + + + + +
+ +
- {/snippet} + + + {m.action_save_changes()} + + +
+ + + + +
{ + if (emailLocked) { + cancel(); + toast.add({ tone: 'danger', message: m.profile_email_locked() }); + return; + } + savingEmail = true; + return async ({ result }) => { + savingEmail = false; + await applyAction(result); + if (result.type === 'success') { + toast.add({ tone: 'info', message: m.profile_email_sent() }); + } else if ( + result.type === 'failure' && + typeof result.data?.emailError === 'string' + ) { + toast.add({ tone: 'danger', message: result.data.emailError }); + } + }; + }} + > -
-
-

Account profile

-

{displayName}

-

- Use this page to shape how your identity appears anywhere CropWatch surfaces profile - data. -

-
- -
-
- Employer - {employer || 'Add your company'} -
-
- Website - {website || 'Add your website'} -
-
- Phone - {phoneNumber || 'Add your phone number'} -
-
-
+ {#if emailLocked} + +

{m.profile_email_locked()}

+
+ {/if} + {#if form?.emailPending} + +

{form.emailMessage ?? m.profile_email_pending()}

+
+ {/if} + {#if form?.emailError} + +

{form.emailError}

+
+ {/if} + + + + + + {m.profile_email_send_action()} + +
- - - {#if hasWebsiteError} - -

Enter a valid domain or URL before saving this profile preview.

-
- {/if} - - event.preventDefault()}> -
- - - - - - - - - - - - - - - - - -
- - - -
- {#if username} - - {/if} - {#if employer} - - {/if} - {#if website} - - {/if} -
- - - - Reset - - - Save profile - - -
-
-
+
- - 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 6f0c9675..06f8f7a4 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'; @@ -55,7 +55,7 @@ } - + ← {m.action_back()} @@ -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}
@@ -80,6 +83,7 @@ {#if !isTrafficDevice} {#each rangeOptions as range (range.value)}
- - - + + +

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

@@ -130,6 +131,7 @@
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]/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.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..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 @@ -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); @@ -73,6 +74,7 @@
@@ -86,6 +88,7 @@ >
- + {#if deviceGroupError}

{deviceGroupError}

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

{ttiNameError}

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

{locationError}

{/if} @@ -195,6 +202,7 @@
- + {#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 }); } diff --git a/src/routes/locations/[location_id]/devices/create/+page.svelte b/src/routes/locations/[location_id]/devices/create/+page.svelte index ef149ea7..40e6cd1b 100644 --- a/src/routes/locations/[location_id]/devices/create/+page.svelte +++ b/src/routes/locations/[location_id]/devices/create/+page.svelte @@ -126,6 +126,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 1ba4ec86..b94bec8e 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,65 +79,136 @@ 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()}
      - (open = false)}> + (open = false)}> {m.action_close()}
      @@ -123,19 +216,41 @@