diff --git a/messages/en.json b/messages/en.json index 8034040b..17920c11 100644 --- a/messages/en.json +++ b/messages/en.json @@ -348,6 +348,7 @@ "rules_new_active": "Active", "rules_new_inactive": "Inactive", "rules_new_assigned_devices": "Assigned Devices", + "rules_new_unknown_location": "Unknown Location", "rules_new_triggered_devices": "Triggered Devices", "rules_new_actions": "Actions", "rules_new_action_type": "Action Type", @@ -1202,5 +1203,57 @@ "cwui_wind_beaufort_names": "Calm,Light air,Light breeze,Gentle breeze,Moderate,Fresh,Strong,Near gale,Gale,Strong gale,Storm,Violent storm,Hurricane", "cwui_wind_sr_wind": "Wind {word} {direction} {cardinal}", "cwui_wind_sr_speed": "Speed {speed}", - "cwui_wind_sr_beaufort": "Beaufort {force}, {label}" + "cwui_wind_sr_beaufort": "Beaufort {force}, {label}", + "reports_new_page_title": "Report Templates - CropWatch", + "reports_new_create_page_title": "Create Report Template - CropWatch", + "reports_new_edit_page_title": "Edit Report Template - CropWatch", + "reports_new_configured_templates": "Report Templates", + "reports_new_create_template": "Create Report Template", + "reports_new_edit_template": "Edit Report Template", + "reports_new_delete_template": "Delete Report Template", + "reports_new_template_id": "Template ID", + "reports_new_status": "Status", + "reports_new_active": "Active", + "reports_new_inactive": "Inactive", + "reports_new_assigned_devices": "Assigned Devices", + "reports_new_recipients": "Recipients", + "reports_new_cadence": "Cadence", + "reports_new_cadence_daily": "Daily", + "reports_new_cadence_weekly": "Weekly", + "reports_new_cadence_monthly": "Monthly", + "reports_new_no_assignments": "No assigned devices", + "reports_new_summary_more": "{summary}, +{count} more", + "reports_new_step_template": "1. Template", + "reports_new_step_template_subtitle": "Name the template, set how often data is pulled, and control whether it is active.", + "reports_new_step_assignments": "2. Device Assignments", + "reports_new_step_assignments_subtitle": "Choose the devices that should use this report template.", + "reports_new_description_placeholder": "Optional notes for the team", + "reports_new_data_pull_interval": "Data pull interval", + "reports_new_active_template": "Active template", + "reports_new_active_template_description": "Active templates generate reports on schedule.", + "reports_new_cadence_subtitle": "Choose when reports are generated and delivered.", + "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_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.", + "reports_new_delete_confirmation": "Are you sure you want to delete the report template \"{name}\"? This cannot be undone.", + "reports_new_load_failed": "Unable to load report templates.", + "reports_new_save_failed": "Unable to save this report template.", + "reports_new_delete_failed": "Unable to delete this report template.", + "reports_new_invalid_template_id": "Invalid report template ID.", + "reports_new_report_template_not_found": "Report template not found.", + "reports_new_view_history": "View History", + "reports_new_history_subtitle": "Generated reports for \"{name}\", grouped by device.", + "reports_new_history_loading": "Loading history…", + "reports_new_history_empty": "No reports have been generated yet.", + "reports_new_history_load_failed": "Unable to load report history.", + "reports_new_download_failed": "Unable to download this report.", + "reports_new_download_missing_url": "Download link is unavailable.", + "reports_new_validation_devices_required": "Select at least one device for this report template.", + "reports_new_validation_cadence_required": "Choose at least one cadence (daily, weekly, or monthly) for the active schedule.", + "reports_new_validation_recipient_email_required": "Recipient {index}: an email address is required." } diff --git a/messages/ja.json b/messages/ja.json index f1a86dd4..5708fc87 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -348,6 +348,7 @@ "rules_new_active": "有効", "rules_new_inactive": "無効", "rules_new_assigned_devices": "割り当てデバイス", + "rules_new_unknown_location": "不明なロケーション", "rules_new_triggered_devices": "発火中デバイス", "rules_new_actions": "アクション", "rules_new_action_type": "アクションタイプ", @@ -1202,5 +1203,57 @@ "cwui_wind_beaufort_names": "静穏,至軽風,軽風,軟風,和風,疾風,雄風,強風,疾強風,大強風,全強風,暴風,颶風", "cwui_wind_sr_wind": "風 {word} {direction} {cardinal}", "cwui_wind_sr_speed": "風速 {speed}", - "cwui_wind_sr_beaufort": "ビューフォート {force}、{label}" + "cwui_wind_sr_beaufort": "ビューフォート {force}、{label}", + "reports_new_page_title": "レポートテンプレート - CropWatch", + "reports_new_create_page_title": "レポートテンプレートの作成 - CropWatch", + "reports_new_edit_page_title": "レポートテンプレートの編集 - CropWatch", + "reports_new_configured_templates": "レポートテンプレート", + "reports_new_create_template": "レポートテンプレートを作成", + "reports_new_edit_template": "レポートテンプレートを編集", + "reports_new_delete_template": "レポートテンプレートを削除", + "reports_new_template_id": "テンプレートID", + "reports_new_status": "ステータス", + "reports_new_active": "有効", + "reports_new_inactive": "無効", + "reports_new_assigned_devices": "割り当てデバイス", + "reports_new_recipients": "受信者", + "reports_new_cadence": "頻度", + "reports_new_cadence_daily": "毎日", + "reports_new_cadence_weekly": "毎週", + "reports_new_cadence_monthly": "毎月", + "reports_new_no_assignments": "割り当てデバイスなし", + "reports_new_summary_more": "{summary}、他 {count} 件", + "reports_new_step_template": "1. テンプレート", + "reports_new_step_template_subtitle": "テンプレートに名前を付け、データ取得間隔を設定し、有効かどうかを管理します。", + "reports_new_step_assignments": "2. デバイスの割り当て", + "reports_new_step_assignments_subtitle": "このレポートテンプレートを使用するデバイスを選択します。", + "reports_new_description_placeholder": "チーム向けの任意のメモ", + "reports_new_data_pull_interval": "データ取得間隔", + "reports_new_active_template": "有効なテンプレート", + "reports_new_active_template_description": "有効なテンプレートはスケジュールに従ってレポートを生成します。", + "reports_new_cadence_subtitle": "レポートを生成して配信するタイミングを選択します。", + "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_created_success": "レポートテンプレート「{name}」を作成しました。", + "reports_new_updated_success": "レポートテンプレート「{name}」を更新しました。", + "reports_new_deleted_success": "レポートテンプレート「{name}」を削除しました。", + "reports_new_delete_confirmation": "レポートテンプレート「{name}」を削除してもよろしいですか?この操作は元に戻せません。", + "reports_new_load_failed": "レポートテンプレートを読み込めませんでした。", + "reports_new_save_failed": "このレポートテンプレートを保存できませんでした。", + "reports_new_delete_failed": "このレポートテンプレートを削除できませんでした。", + "reports_new_invalid_template_id": "無効なレポートテンプレートIDです。", + "reports_new_report_template_not_found": "レポートテンプレートが見つかりません。", + "reports_new_view_history": "履歴を表示", + "reports_new_history_subtitle": "「{name}」の生成済みレポートをデバイスごとにまとめています。", + "reports_new_history_loading": "履歴を読み込み中…", + "reports_new_history_empty": "まだレポートは生成されていません。", + "reports_new_history_load_failed": "レポート履歴を読み込めませんでした。", + "reports_new_download_failed": "このレポートをダウンロードできませんでした。", + "reports_new_download_missing_url": "ダウンロードリンクを利用できません。", + "reports_new_validation_devices_required": "このレポートテンプレートに少なくとも1台のデバイスを選択してください。", + "reports_new_validation_cadence_required": "有効なスケジュールに少なくとも1つの頻度(毎日・毎週・毎月)を選択してください。", + "reports_new_validation_recipient_email_required": "受信者 {index}: メールアドレスが必要です。" } diff --git a/package.json b/package.json index e0bcd889..ca0bf6e3 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ }, "packageManager": "pnpm@10.18.2+sha512.9fb969fa749b3ade6035e0f109f0b8a60b5d08a1a87fdf72e337da90dcc93336e2280ca4e44f2358a649b83c17959e9993e777c2080879f3801e6f0d999ad3dd", "dependencies": { - "@cropwatchdevelopment/cwui": "0.1.101", + "@cropwatchdevelopment/cwui": "0.1.103", "@supabase/supabase-js": "^2.98.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6ca5f98..e5fdb647 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@cropwatchdevelopment/cwui': - specifier: 0.1.101 - version: 0.1.101(svelte@5.53.0) + specifier: 0.1.103 + version: 0.1.103(svelte@5.53.0) '@supabase/supabase-js': specifier: ^2.98.0 version: 2.98.0 @@ -117,8 +117,8 @@ importers: packages: - '@cropwatchdevelopment/cwui@0.1.101': - resolution: {integrity: sha512-Yo9c729uXQO3w9fQ3VUEKdVck4OPayUn/AXEoYGFFIoKsr7kj7FZnI5L08CnOEGsi7WfZc64L2FZ22Hn/qilVg==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.101/6e8fc2964422f44fc18de9f790062f8f38934663} + '@cropwatchdevelopment/cwui@0.1.103': + resolution: {integrity: sha512-EZXs2nqNqhvtLqyA6+pSLsYzPlFEigXlphX62mhVG5kc+DRSKHLMywnIU3SkgU1b7D+6y0WOilOWb2rm9NX5SA==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.103/1e56a28d9c7af92fb2e2b4c03e8887a466e5d8e7} peerDependencies: svelte: ^5.0.0 @@ -2147,7 +2147,7 @@ packages: snapshots: - '@cropwatchdevelopment/cwui@0.1.101(svelte@5.53.0)': + '@cropwatchdevelopment/cwui@0.1.103(svelte@5.53.0)': dependencies: svelte: 5.53.0 diff --git a/src/lib/api/api.dtos.ts b/src/lib/api/api.dtos.ts index 2056ab47..a1866571 100644 --- a/src/lib/api/api.dtos.ts +++ b/src/lib/api/api.dtos.ts @@ -289,6 +289,7 @@ export interface RuleTemplateAssignmentDto { isActive: boolean; createdAt: string | null; deviceName: string | null; + locationName: string | null; permissionLevel: number | null; state: RuleTemplateStateDto | null; } @@ -501,6 +502,160 @@ export interface CreateReportRequest { export type UpdateReportRequest = Partial; +// --- Report templates (reports-new) --------------------------------------- + +export interface ReportTemplateAssignmentDto { + id: number; + devEui: string; + templateId: number; + isActive: boolean; + createdAt: string | null; + deviceName: string | null; + locationName: string | null; + permissionLevel: number | null; +} + +export interface ReportTemplateScheduleDto { + id: number; + templateId: number; + endOfDay: boolean; + endOfWeek: boolean; + endOfMonth: boolean; + utcOffset: number; + isActive: boolean; + createdAt: string | null; +} + +export interface ReportTemplateRecipientDto { + id: number; + templateId: number; + communicationMethod: number; + email: string | null; + name: string | null; + createdAt: string | null; +} + +export interface ReportTemplateAlertPointDto { + id: number; + templateId: number; + name: string; + dataPointKey: string; + operator: string | null; + min: number | null; + max: number | null; + value: number | null; + hexColor: string | null; + createdAt: string | null; +} + +export interface ReportTemplateDataProcessingScheduleDto { + id: string; + templateId: number; + dayOfWeek: number; + startTime: string; + endTime: string; + crossesMidnight: boolean; + ruleType: string; + validFrom: string | null; + validTo: string | null; + timezone: string; + isEnabled: boolean; + createdAt: string | null; + updatedAt: string | null; +} + +export interface ReportTemplateDto { + id: number; + name: string; + description: string | null; + deviceTypeId: number | null; + dataPullInterval: number; + isActive: boolean; + createdAt: string | null; + assignments: ReportTemplateAssignmentDto[]; + schedule: ReportTemplateScheduleDto[]; + recipients: ReportTemplateRecipientDto[]; + alertPoints: ReportTemplateAlertPointDto[]; + dataProcessingSchedules: ReportTemplateDataProcessingScheduleDto[]; +} + +export interface CommunicationMethodDto { + communicationMethodId: number; + name: string | null; + isActive: boolean; +} + +export interface ReportTemplateHistoryItemDto { + devEui: string; + deviceName: string | null; + name: string; + id: string | null; + createdAt: string | null; + updatedAt: string | null; + lastAccessedAt: string | null; + metadata: Record | null; +} + +export interface ReportTemplateScheduleInput { + endOfDay?: boolean; + endOfWeek?: boolean; + endOfMonth?: boolean; + utcOffset?: number; + isActive?: boolean; +} + +export interface ReportTemplateRecipientInput { + communicationMethod: number; + email?: string | null; + name?: string | null; +} + +export interface ReportTemplateAlertPointInput { + name: string; + dataPointKey: string; + operator?: string | null; + min?: number | null; + max?: number | null; + value?: number | null; + hexColor?: string | null; +} + +export interface ReportTemplateDataProcessingScheduleInput { + dayOfWeek: number; + startTime: string; + endTime: string; + crossesMidnight?: boolean; + ruleType?: string; + validFrom?: string | null; + validTo?: string | null; + timezone?: string; + isEnabled?: boolean; +} + +export interface ReportTemplateSaveRequest { + name: string; + description?: string | null; + dataPullInterval?: number; + deviceTypeId?: number | null; + isActive?: boolean; + devEuis: string[]; + schedule?: ReportTemplateScheduleInput[]; + recipients?: ReportTemplateRecipientInput[]; + alertPoints?: ReportTemplateAlertPointInput[]; + dataProcessingSchedules?: ReportTemplateDataProcessingScheduleInput[]; +} + +export interface ReportTemplateListQuery { + search?: string; +} + +export interface ReportFormContextDto { + devices: DeviceDto[]; + locations: LocationDto[]; + communicationMethods: CommunicationMethodDto[]; + template: ReportTemplateDto | null; +} + export type TriggeredRulesCountResponse = | number | { diff --git a/src/lib/api/api.service.ts b/src/lib/api/api.service.ts index da557ae6..6f339a94 100644 --- a/src/lib/api/api.service.ts +++ b/src/lib/api/api.service.ts @@ -24,6 +24,12 @@ import type { PaginationQuery, ReportDto, ReportsQuery, + ReportTemplateDto, + ReportTemplateListQuery, + ReportTemplateSaveRequest, + ReportFormContextDto, + ReportTemplateHistoryItemDto, + CommunicationMethodDto, RuleActionTypeDto, RuleDto, RuleFormContextDto, @@ -158,6 +164,12 @@ const REPORT_DOWNLOAD_ENDPOINT = `${REPORTS_BASE_ENDPOINT}/download/{dev_eui}/{r const RULE_BY_ID_ENDPOINT = `${RULES_BASE_ENDPOINT}/{id}`; const RULE_TEMPLATE_BY_ID_ENDPOINT = `${RULE_TEMPLATES_ENDPOINT}/{id}`; const RULE_TEMPLATE_HISTORY_ENDPOINT = `${RULE_TEMPLATES_ENDPOINT}/{id}/history`; +const REPORT_TEMPLATES_ENDPOINT = publicEnv.PUBLIC_REPORT_TEMPLATES_ENDPOINT ?? '/reports-new'; +const REPORT_TEMPLATE_COMMUNICATION_METHODS_ENDPOINT = `${REPORT_TEMPLATES_ENDPOINT}/communication-methods`; +const REPORT_TEMPLATE_FORM_CONTEXT_ENDPOINT = `${REPORT_TEMPLATES_ENDPOINT}/form-context`; +const REPORT_TEMPLATE_BY_ID_ENDPOINT = `${REPORT_TEMPLATES_ENDPOINT}/{id}`; +const REPORT_TEMPLATE_HISTORY_ENDPOINT = `${REPORT_TEMPLATES_ENDPOINT}/{id}/history`; +const REPORT_TEMPLATE_DOWNLOAD_ENDPOINT = `${REPORT_TEMPLATES_ENDPOINT}/download/{dev_eui}/{reportName}`; const AIR_NOTES_CREATE_ENDPOINT = publicEnv.PUBLIC_AIR_NOTES_ENDPOINT ?? '/air/notes'; const GET_AIR_NOTES_ENDPOINT = publicEnv.PUBLIC_GET_AIR_NOTES_ENDPOINT ?? '/air/notes/{dev_eui}/month/{month}/year/{year}'; @@ -1189,6 +1201,118 @@ export class ApiService { }); } + public getReportTemplates( + query: ReportTemplateListQuery = {}, + options: ApiMethodOptions = {} + ): Promise { + return this.request(REPORT_TEMPLATES_ENDPOINT, { + method: 'GET', + signal: options.signal, + query: { + search: query.search + } + }); + } + + public getReportTemplate(id: number, options: ApiMethodOptions = {}): Promise { + return this.request( + replacePathParams(REPORT_TEMPLATE_BY_ID_ENDPOINT, { id }), + { + method: 'GET', + signal: options.signal + } + ); + } + + public getReportTemplateHistory( + id: number, + options: ApiMethodOptions = {} + ): Promise { + return this.request( + replacePathParams(REPORT_TEMPLATE_HISTORY_ENDPOINT, { id }), + { + method: 'GET', + signal: options.signal + } + ); + } + + public getReportTemplateCommunicationMethods( + options: ApiMethodOptions = {} + ): Promise { + return this.request( + REPORT_TEMPLATE_COMMUNICATION_METHODS_ENDPOINT, + { + method: 'GET', + signal: options.signal + } + ); + } + + public getReportFormContext( + templateId?: number, + options: ApiMethodOptions = {} + ): Promise { + return this.request(REPORT_TEMPLATE_FORM_CONTEXT_ENDPOINT, { + method: 'GET', + signal: options.signal, + query: typeof templateId === 'number' ? { templateId } : undefined + }); + } + + public createReportTemplate( + payload: ReportTemplateSaveRequest, + options: ApiMethodOptions = {} + ): Promise { + return this.request(REPORT_TEMPLATES_ENDPOINT, { + method: 'POST', + body: payload, + signal: options.signal + }); + } + + public updateReportTemplate( + id: number, + payload: ReportTemplateSaveRequest, + options: ApiMethodOptions = {} + ): Promise { + return this.request( + replacePathParams(REPORT_TEMPLATE_BY_ID_ENDPOINT, { id }), + { + method: 'PATCH', + body: payload, + signal: options.signal + } + ); + } + + public deleteReportTemplate( + id: number, + options: ApiMethodOptions = {} + ): Promise<{ id: number }> { + return this.request<{ id: number }>( + replacePathParams(REPORT_TEMPLATE_BY_ID_ENDPOINT, { id }), + { + method: 'DELETE', + signal: options.signal + } + ); + } + + public getReportTemplateDownloadUrl( + devEui: string, + reportName: string, + options: ApiMethodOptions = {} + ): Promise<{ url: string }> { + return this.request<{ url: string }>( + replacePathParams(REPORT_TEMPLATE_DOWNLOAD_ENDPOINT, { dev_eui: devEui, reportName }), + { + method: 'GET', + signal: options.signal + } + ); + } + public createReport(payload: CreateReportRequest): Promise { return this.request(REPORTS_BASE_ENDPOINT, { method: 'POST', diff --git a/src/lib/components/dashboard/DashboardCards.svelte b/src/lib/components/dashboard/DashboardCards.svelte index e8023e8a..6d034e76 100644 --- a/src/lib/components/dashboard/DashboardCards.svelte +++ b/src/lib/components/dashboard/DashboardCards.svelte @@ -163,16 +163,17 @@ function primaryProps(row: DashboardRow) { const col = row.device_type.primary_data_v2; - if (!col || col === '-') return { value: null, unit: '', label: undefined }; + if (!col || col === '-') return { value: null, unit: '', label: undefined, icon: undefined }; const def = labelFor(col); - return readingProps(def.format, def.unit, row.latest?.primary); + return { ...readingProps(def.format, def.unit, row.latest?.primary), icon: def.icon }; } function secondaryProps(row: DashboardRow) { const col = row.device_type.secondary_data_v2; - if (!col || col === '' || col === '-') return { value: null, unit: '', label: undefined }; + if (!col || col === '' || col === '-') + return { value: null, unit: '', label: undefined, icon: undefined }; const def = labelFor(col); - return readingProps(def.format, def.unit, row.latest?.secondary); + return { ...readingProps(def.format, def.unit, row.latest?.secondary), icon: def.icon }; } function detailEntries(details: Record) { @@ -278,9 +279,11 @@ primaryValue={primary.value} primaryUnit={primary.unit} primaryLabel={primary.label} + primary_icon={primary.icon} secondaryValue={secondary.value} secondaryUnit={secondary.unit} secondaryLabel={secondary.label} + secondary_icon={secondary.icon} lastSeenAt={row.last_data_updated_at ?? row.latest?.created_at ?? undefined} expireAfterMinutes={row.upload_interval ?? row.device_type.default_upload_interval ?? diff --git a/src/lib/components/displays/AirDisplay/AirDisplay.svelte b/src/lib/components/displays/AirDisplay/AirDisplay.svelte index f40b4408..a120d0e2 100644 --- a/src/lib/components/displays/AirDisplay/AirDisplay.svelte +++ b/src/lib/components/displays/AirDisplay/AirDisplay.svelte @@ -41,9 +41,43 @@ let noteOverridesByDevice = $state>>({}); let tableRevision = $state(0); let noteOverrides = $derived(noteOverridesByDevice[devEui] ?? {}); - let hasCo2 = $derived( - historicalData.every((row) => row.co2 !== undefined && row.co2 !== null && row.co2 !== 0) + // CO2 is reported on a slower cadence than temp/humidity on some devices (the + // CW-air-co2 device sends it in ~1 of 3 uplinks), so many rows carry a null + // co2. Gate the card on "has at least one real reading" — the old `.every(...)` + // check hid the card whenever a single null/zero row appeared — and compute the + // stats over only the rows that actually carry a reading (treating null as 0 + // otherwise drags min to 0 and skews the average). + let co2Values = $derived( + historicalData + .map((row) => Number(row.co2)) + .filter((value) => Number.isFinite(value) && value > 0) ); + let hasCo2 = $derived(co2Values.length > 0); + let latestCo2: CwStatCardData = $derived.by(() => { + if (co2Values.length === 0) { + return { min: 0, max: 0, avg: 0, median: 0, stdDev: 0, count: 0, lastReading: 0, trend: 'up' }; + } + const sorted = [...co2Values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const median = sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; + const mean = co2Values.reduce((sum, value) => sum + value, 0) / co2Values.length; + const variance = + co2Values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / co2Values.length; + // Most-recent row that actually carries CO2 (historicalData is newest-first). + const lastWithCo2 = historicalData.find( + (row) => Number.isFinite(Number(row.co2)) && Number(row.co2) > 0 + ); + return { + min: sorted[0], + max: sorted[sorted.length - 1], + avg: mean, + median, + stdDev: Math.sqrt(variance), + count: co2Values.length, + lastReading: lastWithCo2 ? Number(lastWithCo2.co2) : sorted[sorted.length - 1], + trend: 'up' + }; + }); // Weather-station wind: render the compass only when the latest reading // carries both speed and direction (stored as text, so coerce to number). @@ -298,28 +332,7 @@ clickToExpand: m.stat_expand(), clickToCollapse: m.stat_collapse() }} - stats={{ - min: historicalData.reduce((min, row) => Math.min(min, Number(row.co2) || 0), Infinity), - max: historicalData.reduce((max, row) => Math.max(max, Number(row.co2) || 0), -Infinity), - avg: - historicalData.reduce((sum, row) => sum + (Number(row.co2) || 0), 0) / - historicalData.length, - median: (() => { - const co2s = historicalData.map((row) => Number(row.co2) || 0).sort((a, b) => a - b); - const mid = Math.floor(co2s.length / 2); - return co2s.length % 2 !== 0 ? co2s[mid] : (co2s[mid - 1] + co2s[mid]) / 2; - })(), - stdDev: (() => { - const co2s = historicalData.map((row) => Number(row.co2) || 0); - const mean = co2s.reduce((sum, value) => sum + value, 0) / co2s.length; - const variance = - co2s.reduce((sum, value) => sum + (value - mean) ** 2, 0) / co2s.length; - return Math.sqrt(variance); - })(), - count: historicalData.length, - lastReading: historicalData.length > 0 ? Number(historicalData.at(0)?.co2) || 0 : 0, - trend: 'up' - }} + stats={latestCo2} unit="ppm" accentColor="purple" /> diff --git a/src/lib/sensor-labels/index.ts b/src/lib/sensor-labels/index.ts index 3f92178b..0c7e08e3 100644 --- a/src/lib/sensor-labels/index.ts +++ b/src/lib/sensor-labels/index.ts @@ -5,7 +5,9 @@ export type SensorFormat = 'number' | 'integer' | 'boolean'; export interface SensorLabel { label: () => string; unit: string; - icon?: 'thermo' | 'drop'; + // `thermo` | `drop` | `co2` are named glyphs in CWUI's CwDataIcon; any other + // string (e.g. the 📏 emoji) is rendered as-is by the card. + icon?: 'thermo' | 'drop' | 'co2' | '📏'; format: SensorFormat; } @@ -13,12 +15,12 @@ const SENSOR_LABELS: Record = { temperature_c: { label: () => m.sensor_temperature(), unit: '°C', icon: 'thermo', format: 'number' }, humidity: { label: () => m.sensor_humidity(), unit: '%', icon: 'drop', format: 'number' }, moisture: { label: () => m.sensor_moisture(), unit: '%', icon: 'drop', format: 'number' }, - co2: { label: () => m.sensor_co2(), unit: 'ppm', format: 'integer' }, + co2: { label: () => m.sensor_co2(), unit: 'ppm', icon: 'co2', format: 'integer' }, co: { label: () => m.sensor_co(), unit: 'ppm', format: 'integer' }, pressure: { label: () => m.sensor_pressure(), unit: 'hPa', format: 'number' }, ec: { label: () => m.sensor_ec(), unit: 'mS/cm', format: 'number' }, ph: { label: () => m.sensor_ph(), unit: '', format: 'number' }, - deapth_cm: { label: () => m.sensor_depth(), unit: 'mm', format: 'number' }, + deapth_cm: { label: () => m.sensor_depth(), unit: 'mm', icon: '📏', format: 'number' }, battery: { label: () => m.sensor_battery(), unit: '%', format: 'integer' }, battery_level: { label: () => m.sensor_battery(), unit: 'V', format: 'number' }, voltage: { label: () => m.sensor_voltage(), unit: 'V', format: 'number' }, diff --git a/src/routes/Sidebar.svelte b/src/routes/Sidebar.svelte index d8988ba1..c2e72c9c 100644 --- a/src/routes/Sidebar.svelte +++ b/src/routes/Sidebar.svelte @@ -76,6 +76,13 @@ icon: { path: REPORTS_ICON_PATH }, group: m.nav_group_info_management() }, + { + id: 'reports-new', + label: `${m.nav_reports()} (beta)`, + href: '/reports-new', + icon: { path: REPORTS_ICON_PATH }, + group: m.nav_group_info_management() + }, { id: 'gateways', label: m.nav_gateways(), diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/device-detail.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/device-detail.ts index dec46eeb..1477a139 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/device-detail.ts +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/device-detail.ts @@ -240,12 +240,19 @@ export function buildSensorChartSeries(rows: TelemetryRow[]): CwResponsiveLineSe const data: CwResponsiveLineDataPoint[] = []; for (const row of rows) { const timestamp = new Date(String(row.created_at)).getTime(); - if (Number.isFinite(timestamp)) { - data.push({ t: timestamp, v: toChartValue(row[column]) }); - } + if (!Number.isFinite(timestamp)) continue; + // Skip null cells instead of pushing them as gap points. A sensor that + // reports a column on a slower cadence than the row cadence (e.g. CO2 on + // the CW-air-co2 device, present in ~1 of 3 rows) would otherwise leave + // every real reading isolated between nulls — and the chart draws a + // single-point run as a dot, not a line. Dropping the gaps lets the + // reading-to-reading line connect. + const value = toChartValue(row[column]); + if (value === null) continue; + data.push({ t: timestamp, v: value }); } data.sort((left, right) => left.t - right.t); - if (!data.some((point) => point.v !== null)) return; + if (data.length === 0) return; const labelInfo = labelFor(column); series.push({ diff --git a/src/routes/locations/[location_id]/settings/LocationEditPermissions.svelte b/src/routes/locations/[location_id]/settings/LocationEditPermissions.svelte index 97a11584..7bbca976 100644 --- a/src/routes/locations/[location_id]/settings/LocationEditPermissions.svelte +++ b/src/routes/locations/[location_id]/settings/LocationEditPermissions.svelte @@ -10,6 +10,7 @@ type CwTableQuery } from '@cropwatchdevelopment/cwui'; import { cwDataTableLabels } from '$lib/i18n/cwuiLabels'; + import { getAppContext } from '$lib/appContext.svelte'; import type { LocationOwnerDto } from '$lib/api/api.dtos'; import { getPermissionLevelLabel, getPermissionLevelOptions } from '$lib/i18n/options'; import { m } from '$lib/paraglide/messages.js'; @@ -22,7 +23,15 @@ } from './location-permission-rows'; let { data }: { data: { locationOwners: LocationOwnerDto[] } } = $props(); - let permissions = $derived(mapLocationOwnersToPermissionRows(data.locationOwners)); + const app = getAppContext(); + // Hide CropWatch staff (@cropwatch.io) permission rows from customers so they are + // unaware we have access — but keep them visible when a CropWatch user is logged in. + const viewerIsCropwatch = $derived(app.session?.email?.includes('@cropwatch.io') ?? false); + let permissions = $derived( + mapLocationOwnersToPermissionRows(data.locationOwners).filter( + (permission) => viewerIsCropwatch || !permission.email.includes('@cropwatch.io') + ) + ); let openDeletePermissionDialog = $state(false); let selectedRow = $state(null); let location_id = $state(page.params.location_id); diff --git a/src/routes/reports-new/+page.svelte b/src/routes/reports-new/+page.svelte new file mode 100644 index 00000000..d13f34de --- /dev/null +++ b/src/routes/reports-new/+page.svelte @@ -0,0 +1,237 @@ + + + + {m.reports_new_page_title()} + + + + goto(backHref(page.url, resolve('/')))}> + ← {m.action_back_to_dashboard()} + + + + {#key tableKey} + + {#snippet cell( + row: ReportTemplateRow, + col: CwColumnDef, + defaultValue: string + )} + {#if col.key === 'statusLabel'} + + {:else} + {defaultValue} + {/if} + {/snippet} + + {#snippet rowActions(row: ReportTemplateRow)} +
+ goto(resolve('/reports-new/edit/[id]', { id: String(row.id) }))} + > + + + + +
+ {/snippet} + + {#snippet toolbarActions()} + goto(resolve('/reports-new/create'))}> + + + {/snippet} +
+ {/key} +
+
+ + diff --git a/src/routes/reports-new/DeleteReportTemplateDialog.svelte b/src/routes/reports-new/DeleteReportTemplateDialog.svelte new file mode 100644 index 00000000..cb2e3300 --- /dev/null +++ b/src/routes/reports-new/DeleteReportTemplateDialog.svelte @@ -0,0 +1,88 @@ + + + (open = true)}> + + + + +

{m.reports_new_delete_confirmation({ name: reportName })}

+ + {#snippet actions()} +
+ (open = false)}> + {m.action_cancel()} + + + {m.action_delete()} + +
+ {/snippet} +
+ + diff --git a/src/routes/reports-new/ReportCadenceSection.svelte b/src/routes/reports-new/ReportCadenceSection.svelte new file mode 100644 index 00000000..9c320faf --- /dev/null +++ b/src/routes/reports-new/ReportCadenceSection.svelte @@ -0,0 +1,90 @@ + + + + +
+
+ (cadence.end_of_day = checked)} + /> + (cadence.end_of_week = checked)} + /> + (cadence.end_of_month = checked)} + /> +
+ +
+ + (cadence.is_active = checked)} + /> +
+
+
+
+ + diff --git a/src/routes/reports-new/ReportProcessingSchedulesSection.svelte b/src/routes/reports-new/ReportProcessingSchedulesSection.svelte new file mode 100644 index 00000000..962aa915 --- /dev/null +++ b/src/routes/reports-new/ReportProcessingSchedulesSection.svelte @@ -0,0 +1,195 @@ + + + + +
+

{m.reports_schedule_card_copy()}

+ + + +
+ + {#if schedules.length === 0} + +

{m.reports_schedule_empty()}

+
+ {/if} + + {#each schedules as schedule, index (schedule.key)} +
+
+
+

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

+
+ onRemove(schedule.key)}> + {m.action_remove()} + +
+ +
+ + + +
+ +
+ + +
+ +
+ (schedule.crosses_midnight = checked)} + /> + (schedule.is_enabled = checked)} + /> +
+
+ {/each} +
+
+ + diff --git a/src/routes/reports-new/ReportRecipientsSection.svelte b/src/routes/reports-new/ReportRecipientsSection.svelte new file mode 100644 index 00000000..2151a299 --- /dev/null +++ b/src/routes/reports-new/ReportRecipientsSection.svelte @@ -0,0 +1,136 @@ + + + + +
+

{m.reports_create_recipients_copy()}

+ + + +
+ + {#if recipients.length === 0} + +

{m.reports_create_empty_recipients()}

+
+ {/if} + + {#each recipients as recipient, index (recipient.key)} +
+
+
+

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

+

{m.reports_create_recipient_copy()}

+
+ {#if recipients.length > 1} + onRemove(recipient.key)} + > + {m.action_remove()} + + {/if} +
+ +
+ + + +
+
+ {/each} +
+
+ + diff --git a/src/routes/reports-new/ReportTemplateForm.svelte b/src/routes/reports-new/ReportTemplateForm.svelte new file mode 100644 index 00000000..c51100de --- /dev/null +++ b/src/routes/reports-new/ReportTemplateForm.svelte @@ -0,0 +1,381 @@ + + +{#if submitAttempted && validationIssues.length > 0} + +
+

{m.reports_create_validation_copy()}

+
+
    + {#each validationIssues as issue (issue)} +
  • {issue}
  • + {/each} +
+
+{/if} + + + + + +
+ +
+ +
+
+ + + + {#if deviceOptions.length === 0} + +

{m.reports_create_no_devices_loaded()}

+
+ {:else} + + {/if} +
+
+ + + + + + + +

{m.reports_create_alerts_copy()}

+ +
+
+ + + + + + + + goto(resolve('/reports-new'))} disabled={submitting}> + {m.action_cancel()} + + + {submitting + ? m.action_saving() + : mode === 'edit' + ? m.action_save_changes() + : m.reports_new_create_template()} + + + + diff --git a/src/routes/reports-new/ViewReportHistoryDialog.svelte b/src/routes/reports-new/ViewReportHistoryDialog.svelte new file mode 100644 index 00000000..32adc8e4 --- /dev/null +++ b/src/routes/reports-new/ViewReportHistoryDialog.svelte @@ -0,0 +1,244 @@ + + + (open = true)}> + + + + +

{m.reports_new_history_subtitle({ name: reportName })}

+ + {#if loading} +

{m.reports_new_history_loading()}

+ {:else if errorMessage} +

{errorMessage}

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

{m.reports_new_history_empty()}

+ {:else} +
    + {#each groups as group (group.devEui)} +
  • + {group.deviceName ?? group.devEui} +
      + {#each group.items as item (item.id ?? item.name)} +
    • +
      + {item.name} + {formatTimestamp(item.createdAt)} +
      + handleDownload(group.devEui, item.name)} + > + {m.action_download()} + +
    • + {/each} +
    +
  • + {/each} +
+ {/if} + + {#snippet actions()} +
+ (open = false)}> + {m.action_close()} + +
+ {/snippet} +
+ + diff --git a/src/routes/reports-new/alert-points-editor-text.ts b/src/routes/reports-new/alert-points-editor-text.ts new file mode 100644 index 00000000..3fe601bd --- /dev/null +++ b/src/routes/reports-new/alert-points-editor-text.ts @@ -0,0 +1,67 @@ +import { getUiLocale } from '$lib/i18n/format'; +import { m } from '$lib/paraglide/messages.js'; +import type { CwAlertPointsEditorText } from '@cropwatchdevelopment/cwui'; + +export function createReportAlertPointsEditorText(): CwAlertPointsEditorText { + return { + unitFieldLabel: m.reports_create_alert_points_unit_label(), + centerFieldLabel: m.reports_create_alert_points_center_label(), + nameFieldLabel: m.common_name(), + conditionFieldLabel: m.reports_create_alert_operator(), + valueFieldLabel: m.reports_create_alert_value(), + minValueFieldLabel: m.reports_create_alert_minimum(), + maxValueFieldLabel: m.reports_create_alert_maximum(), + colorFieldLabel: m.reports_create_alert_hex_color(), + addAlertPointButton: m.reports_create_add_alert(), + removePointButton: m.action_remove(), + emptyTitle: m.reports_create_alert_points_empty_title(), + emptyDescription: m.reports_create_alert_points_empty_description(), + invalidNumberError: m.reports_create_alert_points_invalid_number(), + requiredFieldError: (label) => m.reports_create_alert_points_required_field({ label }), + fieldLabelWithUnit: (label, unit) => + m.reports_create_alert_points_label_with_unit({ label, unit }), + invalidPreviewNote: (count) => + count === 1 + ? m.reports_create_alert_points_invalid_preview_single() + : m.reports_create_alert_points_invalid_preview_multiple({ + count: String(count) + }), + overlapPreviewNote: (count) => + count === 1 + ? m.reports_create_alert_points_overlap_preview_single() + : m.reports_create_alert_points_overlap_preview_multiple({ + count: String(count) + }), + minEqualsMaxWarning: m.reports_create_alert_points_equal_bounds_warning(), + defaultPointName: (index) => m.reports_create_alert_heading({ index: String(index) }), + unitCelsiusLabel: '°C', + unitFahrenheitLabel: '°F', + unitKelvinLabel: '°K', + conditionEqualsLabel: m.reports_create_alert_points_condition_equals(), + conditionRangeLabel: m.reports_create_alert_points_condition_range(), + conditionLessThanLabel: m.reports_create_alert_points_condition_less_than(), + conditionLessThanOrEqualLabel: m.reports_create_alert_points_condition_less_than_or_equal(), + conditionGreaterThanLabel: m.reports_create_alert_points_condition_greater_than(), + conditionGreaterThanOrEqualLabel: + m.reports_create_alert_points_condition_greater_than_or_equal(), + pointDescriptionWaitingForValue: m.reports_create_alert_points_waiting_for_value(), + pointDescriptionWaitingForThreshold: m.reports_create_alert_points_waiting_for_threshold(), + pointDescriptionRangeMissingBounds: m.reports_create_alert_points_range_missing_bounds(), + pointDescriptionEquals: (value, unit) => + m.reports_create_alert_points_description_equals({ value, unit }), + pointDescriptionRange: (min, max, unit) => + m.reports_create_alert_points_description_range({ min, max, unit }), + pointDescriptionLessThan: (value, unit) => + m.reports_create_alert_points_description_less_than({ value, unit }), + pointDescriptionLessThanOrEqual: (value, unit) => + m.reports_create_alert_points_description_less_than_or_equal({ value, unit }), + pointDescriptionGreaterThan: (value, unit) => + m.reports_create_alert_points_description_greater_than({ value, unit }), + pointDescriptionGreaterThanOrEqual: (value, unit) => + m.reports_create_alert_points_description_greater_than_or_equal({ value, unit }), + overlapError: (labels) => + m.reports_create_alert_points_overlap_error({ + labels: labels.join(getUiLocale() === 'ja' ? '、' : ', ') + }) + }; +} diff --git a/src/routes/reports-new/create/+page.server.ts b/src/routes/reports-new/create/+page.server.ts new file mode 100644 index 00000000..2cdeaa05 --- /dev/null +++ b/src/routes/reports-new/create/+page.server.ts @@ -0,0 +1,28 @@ +import { ApiService } from '$lib/api/api.service'; +import type { ReportFormContextDto } from '$lib/api/api.dtos'; +import type { PageServerLoad } from './$types'; + +const EMPTY_CONTEXT: ReportFormContextDto = { + devices: [], + locations: [], + communicationMethods: [], + template: null +}; + +export const load: PageServerLoad = async ({ locals, fetch, url }) => { + const authToken = locals.jwtString ?? null; + const devEui = url.searchParams.get('dev_eui') ?? null; + const jwt = locals.jwt; + const currentUserEmail = jwt?.user_metadata?.email?.trim() || jwt?.email?.trim() || null; + const currentUserName = + jwt?.user_metadata?.full_name?.trim() || jwt?.user_metadata?.name?.trim() || null; + + if (!authToken) { + return { context: EMPTY_CONTEXT, authToken, devEui, currentUserEmail, currentUserName }; + } + + const api = new ApiService({ fetchFn: fetch, authToken }); + const context = await api.getReportFormContext().catch(() => EMPTY_CONTEXT); + + return { context, authToken, devEui, currentUserEmail, currentUserName }; +}; diff --git a/src/routes/reports-new/create/+page.svelte b/src/routes/reports-new/create/+page.svelte new file mode 100644 index 00000000..6a40dc64 --- /dev/null +++ b/src/routes/reports-new/create/+page.svelte @@ -0,0 +1,42 @@ + + + + {m.reports_new_create_page_title()} + + + + goto(resolve('/reports-new'))}> + ← {m.action_back()} + + +
+

{m.reports_new_create_template()}

+
+ + +
+ + diff --git a/src/routes/reports-new/data-pull-interval.ts b/src/routes/reports-new/data-pull-interval.ts new file mode 100644 index 00000000..84f4c22d --- /dev/null +++ b/src/routes/reports-new/data-pull-interval.ts @@ -0,0 +1,73 @@ +export type SelectOption = { + disabled?: boolean; + label: string; + value: string; +}; + +export const DEFAULT_REPORT_DATA_PULL_INTERVAL = '30'; + +const BASE_REPORT_DATA_PULL_INTERVAL_OPTIONS: ReadonlyArray = Object.freeze([ + { label: '30 minutes', value: '30' }, + { label: '1 hour', value: '60' } +]); + +function readPositiveInteger(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + return value; + } + + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + if (!normalized) { + return undefined; + } + + const parsed = Number.parseInt(normalized, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +function formatIntervalLabel(minutes: number): string { + if (minutes % 60 === 0) { + const hours = minutes / 60; + return hours === 1 ? '1 hour' : `${hours} hours`; + } + + return `${minutes} minutes`; +} + +export function parseReportDataPullInterval(value: unknown): number | undefined { + return readPositiveInteger(value); +} + +export function normalizeReportDataPullInterval( + value: unknown, + fallback = DEFAULT_REPORT_DATA_PULL_INTERVAL +): string { + return String(readPositiveInteger(value) ?? fallback); +} + +export function buildReportDataPullIntervalOptions( + currentValue: unknown +): ReadonlyArray { + const normalizedValue = readPositiveInteger(currentValue); + + if ( + normalizedValue === undefined || + BASE_REPORT_DATA_PULL_INTERVAL_OPTIONS.some( + (option) => option.value === String(normalizedValue) + ) + ) { + return BASE_REPORT_DATA_PULL_INTERVAL_OPTIONS; + } + + return [ + { + label: formatIntervalLabel(normalizedValue), + value: String(normalizedValue) + }, + ...BASE_REPORT_DATA_PULL_INTERVAL_OPTIONS + ]; +} diff --git a/src/routes/reports-new/edit/[id]/+page.server.ts b/src/routes/reports-new/edit/[id]/+page.server.ts new file mode 100644 index 00000000..4a990000 --- /dev/null +++ b/src/routes/reports-new/edit/[id]/+page.server.ts @@ -0,0 +1,37 @@ +import { ApiService, ApiServiceError } from '$lib/api/api.service'; +import type { ReportFormContextDto } from '$lib/api/api.dtos'; +import { m } from '$lib/paraglide/messages.js'; +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, fetch, params }) => { + const authToken = locals.jwtString ?? null; + const userId = locals.jwt?.sub ?? null; + const templateId = Number(params.id); + + if (!authToken || !userId) { + error(401, m.error_unauthorized_title()); + } + + if (!Number.isInteger(templateId) || templateId <= 0) { + error(400, m.reports_new_invalid_template_id()); + } + + const api = new ApiService({ fetchFn: fetch, authToken }); + let context: ReportFormContextDto; + try { + context = await api.getReportFormContext(templateId); + } catch (loadError) { + if (loadError instanceof ApiServiceError && loadError.status === 404) { + error(404, m.reports_new_report_template_not_found()); + } + console.error('Failed to load report template:', loadError); + error(500, m.reports_new_load_failed()); + } + + if (!context.template) { + error(404, m.reports_new_report_template_not_found()); + } + + return { context, authToken }; +}; diff --git a/src/routes/reports-new/edit/[id]/+page.svelte b/src/routes/reports-new/edit/[id]/+page.svelte new file mode 100644 index 00000000..3d0b5132 --- /dev/null +++ b/src/routes/reports-new/edit/[id]/+page.svelte @@ -0,0 +1,49 @@ + + + + {m.reports_new_edit_page_title()} + + + + goto(resolve('/reports-new'))}> + ← {m.action_back()} + + +
+

{m.reports_new_edit_template()}

+ +
+ + +
+ + diff --git a/src/routes/reports-new/report-template-form.ts b/src/routes/reports-new/report-template-form.ts new file mode 100644 index 00000000..bed6a298 --- /dev/null +++ b/src/routes/reports-new/report-template-form.ts @@ -0,0 +1,412 @@ +import type { + ReportTemplateAlertPointInput, + ReportTemplateDataProcessingScheduleInput, + ReportTemplateDto, + ReportTemplateRecipientInput, + ReportTemplateSaveRequest, + ReportTemplateScheduleInput +} from '$lib/api/api.dtos'; +import { m } from '$lib/paraglide/messages.js'; +import type { CwAlertPointCondition, CwAlertPointRule, CwAlertPointsValue } from '@cropwatchdevelopment/cwui'; +import { + DEFAULT_REPORT_DATA_PULL_INTERVAL, + normalizeReportDataPullInterval, + parseReportDataPullInterval +} from './data-pull-interval'; + +export type SelectOption = { + disabled?: boolean; + label: string; + value: string; +}; + +export type CadenceDraft = { + end_of_day: boolean; + end_of_week: boolean; + end_of_month: boolean; + utc_offset: string; + is_active: boolean; + key: string; +}; + +export type RecipientDraft = { + communication_method: string; + email: string; + name: string; + key: string; +}; + +export type DataProcessingScheduleDraft = { + crosses_midnight: boolean; + day_of_week: string; + end_time: string; + is_enabled: boolean; + key: string; + rule_type: string; + start_time: string; + timezone: string; +}; + +export type ReportTemplateDraft = { + name: string; + description: string; + data_pull_interval: string; + is_active: boolean; + cadence: CadenceDraft; + recipients: RecipientDraft[]; + data_processing_schedules: DataProcessingScheduleDraft[]; +}; + +const DEFAULT_ALERT_DATA_POINT_KEY = 'temperature_c'; + +export function cleanOptional(value: string): string | undefined { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function parseOptionalInteger(value: string): number | undefined { + const normalized = cleanOptional(value); + if (!normalized) return undefined; + + const parsed = Number.parseInt(normalized, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function parseOptionalNumber(value: string): number | undefined { + const normalized = cleanOptional(value); + if (!normalized) return undefined; + + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function createEmptyAlertPointsValue(): CwAlertPointsValue { + return { + unit: 'C', + center: '0', + points: [] + }; +} + +function mapAlertConditionToOperator(condition: CwAlertPointCondition): string { + switch (condition) { + case 'greaterThan': + return '>'; + case 'greaterThanOrEqual': + return '>='; + case 'lessThan': + return '<'; + case 'lessThanOrEqual': + return '<='; + case 'range': + return 'range'; + case 'equals': + default: + return '='; + } +} + +function mapOperatorToAlertCondition(operator: string | null | undefined): CwAlertPointCondition { + switch (operator) { + case '>': + return 'greaterThan'; + case '>=': + return 'greaterThanOrEqual'; + case '<': + return 'lessThan'; + case '<=': + return 'lessThanOrEqual'; + case 'range': + return 'range'; + case '=': + default: + return 'equals'; + } +} + +export function createReportTemplateDraftFactory(nextRowKey: (prefix: string) => string) { + function createEmptyCadence(): CadenceDraft { + return { + end_of_day: false, + end_of_week: true, + end_of_month: false, + utc_offset: '0', + is_active: true, + key: nextRowKey('cadence') + }; + } + + function createEmptyRecipient(email = '', name = ''): RecipientDraft { + return { + communication_method: '1', + email, + name, + key: nextRowKey('recipient') + }; + } + + function createEmptyDataProcessingSchedule(): DataProcessingScheduleDraft { + return { + crosses_midnight: false, + day_of_week: '1', + end_time: '17:00', + is_enabled: true, + key: nextRowKey('dps'), + rule_type: 'include', + start_time: '09:00', + timezone: 'UTC' + }; + } + + function buildDefaultDraft(currentUserEmail = '', currentUserName = ''): ReportTemplateDraft { + return { + name: '', + description: '', + data_pull_interval: DEFAULT_REPORT_DATA_PULL_INTERVAL, + is_active: true, + cadence: createEmptyCadence(), + recipients: [createEmptyRecipient(currentUserEmail, currentUserName)], + data_processing_schedules: [] + }; + } + + function buildDraftFromTemplate(template: ReportTemplateDto): ReportTemplateDraft { + const firstSchedule = template.schedule[0]; + + const cadence: CadenceDraft = firstSchedule + ? { + end_of_day: firstSchedule.endOfDay, + end_of_week: firstSchedule.endOfWeek, + end_of_month: firstSchedule.endOfMonth, + utc_offset: String(firstSchedule.utcOffset ?? 0), + is_active: firstSchedule.isActive, + key: nextRowKey('cadence') + } + : createEmptyCadence(); + + const recipients = + template.recipients.length > 0 + ? template.recipients.map((recipient) => ({ + communication_method: String(recipient.communicationMethod ?? 1), + email: recipient.email ?? '', + name: recipient.name ?? '', + key: nextRowKey('recipient') + })) + : [createEmptyRecipient()]; + + const dataProcessingSchedules = template.dataProcessingSchedules.map((schedule) => ({ + crosses_midnight: schedule.crossesMidnight ?? false, + day_of_week: String(schedule.dayOfWeek ?? 1), + end_time: schedule.endTime ?? '17:00', + is_enabled: schedule.isEnabled ?? true, + key: nextRowKey('dps'), + rule_type: schedule.ruleType ?? 'include', + start_time: schedule.startTime ?? '09:00', + timezone: schedule.timezone ?? 'UTC' + })); + + return { + name: template.name ?? '', + description: template.description ?? '', + data_pull_interval: normalizeReportDataPullInterval(template.dataPullInterval), + is_active: template.isActive ?? true, + cadence, + recipients, + data_processing_schedules: dataProcessingSchedules + }; + } + + function buildAlertPointsValueFromTemplate(template: ReportTemplateDto): CwAlertPointsValue { + const baseValue = createEmptyAlertPointsValue(); + const points: CwAlertPointRule[] = template.alertPoints.map((point, index) => { + const condition = + typeof point.operator === 'string' && point.operator.trim().length > 0 + ? mapOperatorToAlertCondition(point.operator) + : point.min != null || point.max != null + ? 'range' + : 'equals'; + + return { + color: cleanOptional(point.hexColor ?? '')?.toUpperCase() ?? '', + condition, + id: point.id != null ? String(point.id) : nextRowKey(`alert-${index + 1}`), + max: point.max != null ? String(point.max) : '', + min: point.min != null ? String(point.min) : '', + name: point.name ?? '', + value: point.value != null ? String(point.value) : '' + }; + }); + + return { ...baseValue, points }; + } + + return { + buildAlertPointsValueFromTemplate, + buildDefaultDraft, + buildDraftFromTemplate, + createEmptyDataProcessingSchedule, + createEmptyRecipient + }; +} + +function buildAlertPoints(alertPoints: CwAlertPointsValue): ReportTemplateAlertPointInput[] { + return alertPoints.points.map((point) => { + const input: ReportTemplateAlertPointInput = { + dataPointKey: DEFAULT_ALERT_DATA_POINT_KEY, + name: point.name.trim() + }; + + const hexColor = cleanOptional(point.color)?.toUpperCase(); + const max = parseOptionalNumber(point.max); + const min = parseOptionalNumber(point.min); + const operator = cleanOptional(mapAlertConditionToOperator(point.condition)); + const value = parseOptionalNumber(point.value); + + if (hexColor) input.hexColor = hexColor; + if (max !== undefined) input.max = max; + if (min !== undefined) input.min = min; + if (operator) input.operator = operator; + if (value !== undefined) input.value = value; + + return input; + }); +} + +export function buildSaveRequest( + draft: ReportTemplateDraft, + devEuis: string[], + alertPoints: CwAlertPointsValue, + deviceTypeId: number | null +): ReportTemplateSaveRequest { + const dataPullInterval = parseReportDataPullInterval(draft.data_pull_interval); + + const schedule: ReportTemplateScheduleInput[] = [ + { + endOfDay: draft.cadence.end_of_day, + endOfWeek: draft.cadence.end_of_week, + endOfMonth: draft.cadence.end_of_month, + utcOffset: parseOptionalInteger(draft.cadence.utc_offset) ?? 0, + isActive: draft.cadence.is_active + } + ]; + + const recipients: ReportTemplateRecipientInput[] = draft.recipients.map((recipient) => { + const input: ReportTemplateRecipientInput = { + communicationMethod: parseOptionalInteger(recipient.communication_method) ?? 1 + }; + + const email = cleanOptional(recipient.email); + const name = cleanOptional(recipient.name); + + if (email) input.email = email; + if (name) input.name = name; + + return input; + }); + + const dataProcessingSchedules: ReportTemplateDataProcessingScheduleInput[] = + draft.data_processing_schedules + .filter((schedule) => schedule.day_of_week && schedule.start_time && schedule.end_time) + .map((schedule) => { + const input: ReportTemplateDataProcessingScheduleInput = { + dayOfWeek: Number.parseInt(schedule.day_of_week, 10), + startTime: schedule.start_time, + endTime: schedule.end_time + }; + + input.crossesMidnight = schedule.crosses_midnight; + input.isEnabled = schedule.is_enabled; + if (schedule.rule_type) input.ruleType = schedule.rule_type; + if (schedule.timezone) input.timezone = schedule.timezone; + + return input; + }); + + const payload: ReportTemplateSaveRequest = { + name: draft.name.trim(), + description: cleanOptional(draft.description) ?? null, + isActive: draft.is_active, + deviceTypeId, + devEuis, + schedule, + recipients, + alertPoints: buildAlertPoints(alertPoints), + dataProcessingSchedules + }; + + if (dataPullInterval !== undefined) payload.dataPullInterval = dataPullInterval; + + return payload; +} + +function buildAlertValidationIssues(alertPoints: CwAlertPointsValue): string[] { + const issues: string[] = []; + + alertPoints.points.forEach((point, index) => { + const rowIndex = String(index + 1); + + if (!point.name.trim()) { + issues.push(m.reports_create_validation_alert_name({ index: rowIndex })); + } + + if (point.condition === 'range') { + const min = parseOptionalNumber(point.min); + const max = parseOptionalNumber(point.max); + + if (min === undefined || max === undefined) { + issues.push(m.reports_create_validation_alert_range_required({ index: rowIndex })); + } else if (min > max) { + issues.push(m.reports_create_validation_alert_range_order({ index: rowIndex })); + } + } else if (parseOptionalNumber(point.value) === undefined) { + issues.push(m.reports_create_validation_alert_value({ index: rowIndex })); + } + + const hexColor = cleanOptional(point.color); + if (hexColor && !/^#[0-9a-fA-F]{6}$/.test(hexColor)) { + issues.push(m.reports_create_validation_alert_hex({ index: rowIndex })); + } + }); + + return issues; +} + +export function buildValidationIssues( + draft: ReportTemplateDraft, + devEuis: string[], + alertPoints: CwAlertPointsValue +): string[] { + const issues: string[] = []; + + if (!draft.name.trim()) { + issues.push(m.reports_create_validation_report_name_required()); + } + + if (devEuis.length === 0) { + issues.push(m.reports_new_validation_devices_required()); + } + + if (draft.cadence.is_active && !draft.cadence.end_of_week && !draft.cadence.end_of_day && !draft.cadence.end_of_month) { + issues.push(m.reports_new_validation_cadence_required()); + } + + issues.push(...buildAlertValidationIssues(alertPoints)); + + draft.recipients.forEach((recipient, index) => { + const rowIndex = String(index + 1); + const communicationMethod = parseOptionalInteger(recipient.communication_method); + const email = cleanOptional(recipient.email); + + if (!communicationMethod) { + issues.push(m.reports_create_validation_recipient_method({ index: rowIndex })); + } + + if (!email) { + issues.push(m.reports_new_validation_recipient_email_required({ index: rowIndex })); + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + issues.push(m.reports_create_validation_recipient_email({ index: rowIndex })); + } + }); + + return issues; +} diff --git a/src/routes/rules-new/+page.svelte b/src/routes/rules-new/+page.svelte index f67f70d6..30650630 100644 --- a/src/routes/rules-new/+page.svelte +++ b/src/routes/rules-new/+page.svelte @@ -28,6 +28,7 @@ type RuleTemplateRow = RuleTemplateDto & { statusLabel: string; + locationName: string; assignmentSummary: string; criteriaSummary: string; actionSummary: string; @@ -38,11 +39,12 @@ const SUBJECT_OPTIONS = getRuleSubjectOptions(); const columns: CwColumnDef[] = [ { key: 'name', header: m.common_name(), sortable: true }, - { key: 'statusLabel', header: m.rules_new_status(), sortable: true }, + // { 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: 'actionSummary', header: m.rules_new_actions() }, - { key: 'triggeredCount', header: m.rules_new_triggered_devices(), sortable: true }, + // { key: 'triggeredCount', header: m.rules_new_triggered_devices(), sortable: true }, { key: 'createdAtLabel', header: m.common_created(), sortable: true } ]; @@ -90,6 +92,7 @@ return { ...template, statusLabel: template.isActive ? m.rules_new_active() : m.rules_new_inactive(), + locationName: summarizeLocations(template), assignmentSummary: summarizeAssignments(template), criteriaSummary: summarizeCriteria(template), actionSummary: summarizeActions(template), @@ -100,6 +103,15 @@ }; } + function summarizeLocations(template: RuleTemplateDto): string { + const names = template.assignments + .map((assignment) => assignment.locationName) + .filter((name): name is string => !!name && name.trim().length > 0); + const unique = [...new Set(names)]; + if (unique.length === 0) return m.rules_new_unknown_location(); + return unique.length === 1 ? unique[0] : unique.join(', '); + } + function summarizeAssignments(template: RuleTemplateDto): string { if (template.assignments.length === 0) return m.rules_new_no_assignments(); @@ -172,7 +184,15 @@ {#key tableKey} - + {#snippet cell( row: RuleTemplateRow, col: CwColumnDef,