diff --git a/README.md b/README.md index 0002a800..de0dcd5b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +[![Maintainability](https://qlty.sh/gh/CropWatchDevelopment/projects/CropWatch/maintainability.svg)](https://qlty.sh/gh/CropWatchDevelopment/projects/CropWatch) + ## Creating a project If you're seeing this, you've probably already done this step. Congrats! diff --git a/messages/en.json b/messages/en.json index 2a0b7645..3a84cf7d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -749,6 +749,7 @@ "display_add_note": "Add Note", "display_add_note_body": "Add a note for the telemetry entry at {createdAt} with temperature {temperature}°C.", "display_add_note_title": "Add Note for {createdAt}", + "display_include_in_report": "Include in report", "display_current_co2": "Current CO2", "display_current_humidity": "Current Humidity", "display_current_state": "Current state", @@ -756,6 +757,19 @@ "display_depth": "Depth", "display_device_data": "Device Data", "display_enter_note_here": "Enter your note here...", + "display_note_body_label": "Note details", + "display_note_load_failed": "Failed to load notes for the selected month.", + "display_note_loading": "Loading notes…", + "display_note_no_notes_for_date": "No notes for this date.", + "display_note_no_notes_for_month": "No notes found for the selected month.", + "display_note_no_search_results": "No notes match your search.", + "display_note_report_excluded": "Excluded from report", + "display_note_report_included": "Included in report", + "display_note_search_placeholder": "Search notes...", + "display_note_show_only_dates_with_notes": "Show only dates with notes", + "display_note_title_label": "Note title", + "display_note_title_placeholder": "Enter a short title", + "display_note_untitled": "Untitled note", "display_generic_view": "Generic view", "display_last_updated": "Last updated", "display_latest_reading": "Latest reading", diff --git a/messages/ja.json b/messages/ja.json index a3ce2e24..689065ef 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -749,6 +749,7 @@ "display_add_note": "メモを追加", "display_add_note_body": "{createdAt} のテレメトリエントリに、温度 {temperature}°C のメモを追加します。", "display_add_note_title": "{createdAt} のメモを追加", + "display_include_in_report": "レポートに含める", "display_current_co2": "現在の CO2", "display_current_humidity": "現在の湿度", "display_current_state": "現在の状態", @@ -756,6 +757,19 @@ "display_depth": "深さ", "display_device_data": "デバイスデータ", "display_enter_note_here": "ここにメモを入力してください...", + "display_note_body_label": "メモ内容", + "display_note_load_failed": "選択した月のメモを読み込めませんでした。", + "display_note_loading": "メモを読み込み中…", + "display_note_no_notes_for_date": "この日付のメモはありません。", + "display_note_no_notes_for_month": "選択した月のメモはありません。", + "display_note_no_search_results": "検索条件に一致するメモはありません。", + "display_note_report_excluded": "レポートに含めない", + "display_note_report_included": "レポートに含める", + "display_note_search_placeholder": "メモを検索...", + "display_note_show_only_dates_with_notes": "メモがある日付のみ表示", + "display_note_title_label": "メモのタイトル", + "display_note_title_placeholder": "短いタイトルを入力してください", + "display_note_untitled": "無題のメモ", "display_generic_view": "汎用表示", "display_last_updated": "最終更新", "display_latest_reading": "最新の読み取り", diff --git a/package.json b/package.json index ad289c9f..eb8f8764 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ }, "packageManager": "pnpm@10.18.2+sha512.9fb969fa749b3ade6035e0f109f0b8a60b5d08a1a87fdf72e337da90dcc93336e2280ca4e44f2358a649b83c17959e9993e777c2080879f3801e6f0d999ad3dd", "dependencies": { - "@cropwatchdevelopment/cwui": "0.1.74", + "@cropwatchdevelopment/cwui": "0.1.77", "@supabase/supabase-js": "^2.98.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f6fb389..ec46b2f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@cropwatchdevelopment/cwui': - specifier: 0.1.74 - version: 0.1.74(svelte@5.53.0) + specifier: 0.1.77 + version: 0.1.77(svelte@5.53.0) '@supabase/supabase-js': specifier: ^2.98.0 version: 2.98.0 @@ -114,8 +114,8 @@ importers: packages: - '@cropwatchdevelopment/cwui@0.1.74': - resolution: {integrity: sha512-f90g0EgVOsH42w3LLkUeGbJ1zMCYcJVz5oyG5SvTB+syLdlxGJaMBo1VjMVPkfKwsxWzir0EdnwO+O4izY2MzQ==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.74/a6f76aa402ff6255796eb0b99b294579cc7da682} + '@cropwatchdevelopment/cwui@0.1.77': + resolution: {integrity: sha512-/ME5yHnsfC3sPHdy8e2iIC2oscIfpUbbUImIAvPHnKgo3BAyLWAtFI6jVk228vUrdXIqMX6b5beRofHBHKYV4g==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.77/312b39567e38d6375fff6f1b37a0e0633cc298e3} peerDependencies: svelte: ^5.0.0 @@ -2074,7 +2074,7 @@ packages: snapshots: - '@cropwatchdevelopment/cwui@0.1.74(svelte@5.53.0)': + '@cropwatchdevelopment/cwui@0.1.77(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 6394988a..1a34374e 100644 --- a/src/lib/api/api.dtos.ts +++ b/src/lib/api/api.dtos.ts @@ -305,6 +305,7 @@ export interface ReportDto { id: number; report_id: string; name: string; + data_pull_interval?: number | null; dev_eui: string; created_at: string; user_id?: string | null; @@ -317,6 +318,7 @@ export interface ReportDto { export interface CreateReportRequest { created_at?: string; + data_pull_interval?: number; dev_eui: string; id?: number; name: string; diff --git a/src/lib/api/api.service.spec.ts b/src/lib/api/api.service.spec.ts index b4e0519f..103226ae 100644 --- a/src/lib/api/api.service.spec.ts +++ b/src/lib/api/api.service.spec.ts @@ -57,13 +57,17 @@ describe('ApiService created_at timezone handling', () => { }); await api.createAirNote({ + title: 'Fan maintenance', note: 'Check telemetry row', + include_in_report: true, created_at: '2026-03-08T07:00:00.000-05:00', dev_eui: 'device-123' }); expect(JSON.parse(serializedBody)).toEqual({ + title: 'Fan maintenance', note: 'Check telemetry row', + include_in_report: true, created_at: '2026-03-08T12:00:00.000Z', dev_eui: 'device-123' }); diff --git a/src/lib/api/api.service.ts b/src/lib/api/api.service.ts index a13fda07..1c80ffbf 100644 --- a/src/lib/api/api.service.ts +++ b/src/lib/api/api.service.ts @@ -134,7 +134,9 @@ const TRIGGERED_RULES_COUNT_ENDPOINT = const REPORT_HISTORY_ENDPOINT = `${REPORTS_BASE_ENDPOINT}/history/{dev_eui}`; const REPORT_DOWNLOAD_ENDPOINT = `${REPORTS_BASE_ENDPOINT}/download/{dev_eui}/{report_id}/{reportName}`; const RULE_BY_ID_ENDPOINT = `${RULES_BASE_ENDPOINT}/{id}`; -const AIR_NOTES_CREATE_ENDPOINT = publicEnv.PUBLIC_AIR_NOTES_ENDPOINT ?? '/air-notes'; +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}'; const SOIL_ENDPOINT = '/soil/{dev_eui}'; const TRAFFIC_ENDPOINT = '/traffic/{dev_eui}'; const TRAFFIC_MONTHLY_ENDPOINT = '/traffic/{dev_eui}/monthly'; @@ -935,6 +937,8 @@ export class ApiService { public createAirNote(payload: { note: string; + title: string; + include_in_report: boolean; created_at: string; dev_eui: string; }): Promise | null> { @@ -944,6 +948,20 @@ export class ApiService { }); } + public getAllAirNotesForMonth(payload: { + dev_eui: string; + date: Date; + }): Promise | null> { + const requestUrl = replacePathParams(GET_AIR_NOTES_ENDPOINT, { + dev_eui: payload.dev_eui, + month: String(payload.date.getMonth() + 1).padStart(2, '0'), + year: String(payload.date.getFullYear()) + }); + return this.request | null>(requestUrl, { + method: 'GET' + }); + } + public updateDevice( devEui: string, payload: Record diff --git a/src/lib/components/displays/AirDisplay/AirDisplay.svelte b/src/lib/components/displays/AirDisplay/AirDisplay.svelte index b9cd2c6d..f4e3737e 100644 --- a/src/lib/components/displays/AirDisplay/AirDisplay.svelte +++ b/src/lib/components/displays/AirDisplay/AirDisplay.svelte @@ -23,6 +23,7 @@ import type { AirRow } from './interfaces/AirRow.interface'; import type { Note } from './interfaces/note.interface'; import NotesViewDialog from './dialogs/notes-view-dialog.svelte'; + import { parseAirNotesResponse } from './utils/air-notes'; import Icon from '$lib/components/Icon.svelte'; import CHECK_CIRCLE_ICON from '$lib/images/icons/check_circle.svg'; @@ -43,7 +44,7 @@ let noteOverridesByDevice = $state>>({}); let tableRevision = $state(0); let noteOverrides = $derived(noteOverridesByDevice[devEui] ?? {}); - let hasCo2: boolean = $state( + let hasCo2 = $derived( historicalData.every((row) => row.co2 !== undefined && row.co2 !== null && row.co2 !== 0) ); @@ -57,16 +58,7 @@ dev_eui: String(row.dev_eui ?? ''), alerts: Array.isArray(row.alerts) ? (row.alerts as AirRow['alerts']) : [], cw_air_annotations: - noteOverrides[String(row.created_at ?? '')] ?? - (Array.isArray(row.cw_air_annotations) - ? row.cw_air_annotations.map((annotation) => ({ - id: String( - annotation.id ?? `${annotation.created_at ?? 'note'}-${annotation.note ?? index}` - ), - note: String(annotation.note ?? ''), - created_at: String(annotation.created_at ?? '') - })) - : []) + noteOverrides[String(row.created_at ?? '')] ?? parseAirNotesResponse(row.cw_air_annotations) })); } @@ -121,10 +113,7 @@ return Math.sqrt(variance); })(), count: historicalData.length, - lastReading: - historicalData.length > 0 - ? Number(historicalData.at(0)?.temperature_c) || 0 - : 0, + lastReading: historicalData.length > 0 ? Number(historicalData.at(0)?.temperature_c) || 0 : 0, trend: 'up' }; }); @@ -147,10 +136,7 @@ return Math.sqrt(variance); })(), count: historicalData.length, - lastReading: - historicalData.length > 0 - ? Number(historicalData.at(0)?.humidity) || 0 - : 0, + lastReading: historicalData.length > 0 ? Number(historicalData.at(0)?.humidity) || 0 : 0, trend: 'up' }; }); @@ -361,10 +347,7 @@ return Math.sqrt(variance); })(), count: historicalData.length, - lastReading: - historicalData.length > 0 - ? Number(historicalData.at(0)?.co2) || 0 - : 0, + lastReading: historicalData.length > 0 ? Number(historicalData.at(0)?.co2) || 0 : 0, trend: 'up' }} unit="ppm" @@ -412,10 +395,7 @@ - + {#key tableKey} void | Promise; }; + const NOTE_TITLE_MAX_LENGTH = 500; + const NOTE_BODY_MAX_LENGTH = 500; + let { row, dev_eui, onSaved }: NotesCreateDialogProps = $props(); const toast = useCwToast(); let open = $state(false); let noteText = $state(''); + let noteTitle = $state(''); + let includeInReport = $state(true); let saving = $state(false); + let formattedCreatedAt = $derived(formatDateTime(row.created_at)); function getResultMessage(result: ActionResult): string | null { if (result.type !== 'success' && result.type !== 'failure') return null; @@ -31,11 +44,25 @@ return typeof message === 'string' && message.trim().length > 0 ? message.trim() : null; } - function createOptimisticNote(note: string): Note { + function resetForm() { + noteText = ''; + noteTitle = ''; + includeInReport = true; + } + + function closeDialog() { + open = false; + resetForm(); + } + + function createOptimisticNote(title: string, note: string, reportIncluded: boolean): Note { return { id: globalThis.crypto?.randomUUID?.() ?? `note-${Date.now()}`, - created_at: new Date().toISOString(), - note + created_at: row.created_at, + created_by: '', + title, + note, + includeInReport: reportIncluded }; } @@ -50,10 +77,13 @@ if (saving) return false; const trimmedNote = noteText.trim(); + const trimmedTitle = noteTitle.trim(); if (!trimmedNote) return false; const formData = new FormData(); + formData.append('title', trimmedTitle); formData.append('note', trimmedNote); + formData.append('include_in_report', includeInReport ? 'true' : 'false'); formData.append('created_at', row.created_at); formData.append('dev_eui', dev_eui); @@ -73,9 +103,8 @@ tone: 'success', message: message ?? m.devices_save_note_success() }); - await onSaved?.(createOptimisticNote(trimmedNote)); - open = false; - noteText = ''; + await onSaved?.(createOptimisticNote(trimmedTitle, trimmedNote, includeInReport)); + closeDialog(); return true; } @@ -97,29 +126,49 @@ } - (open = true)}> + { + resetForm(); + open = true; + }} +> (open = false)} - title={m.display_add_note_title({ createdAt: formatDateTime(row.created_at) })} + onclose={closeDialog} + title={m.display_add_note_title({ createdAt: formattedCreatedAt })} > - - -

{noteText.length}/500

+
+ (noteTitle = (e.target as HTMLInputElement).value)} + placeholder={m.display_note_title_placeholder()} + maxlength={NOTE_TITLE_MAX_LENGTH} + > + +
+

{m.display_note_body_label()}

+
+ +

{noteText.length}/{NOTE_BODY_MAX_LENGTH}

+ +
{#snippet actions()} - (open = false)}> + {m.action_cancel()} @@ -129,13 +178,42 @@ disabled={saving || noteText.trim().length === 0} onclick={() => handleSaveNote()} > - + {m.display_save_note()} {/snippet}
diff --git a/src/lib/components/displays/AirDisplay/interfaces/note.interface.ts b/src/lib/components/displays/AirDisplay/interfaces/note.interface.ts index 7b169821..a92a712a 100644 --- a/src/lib/components/displays/AirDisplay/interfaces/note.interface.ts +++ b/src/lib/components/displays/AirDisplay/interfaces/note.interface.ts @@ -1,5 +1,8 @@ export interface Note { - id: string; - created_at: string; - note: string; -} \ No newline at end of file + id: string; + created_at: string; + created_by: string; + title: string; + note: string; + includeInReport: boolean; +} diff --git a/src/lib/components/displays/AirDisplay/utils/air-notes.ts b/src/lib/components/displays/AirDisplay/utils/air-notes.ts new file mode 100644 index 00000000..7e08c682 --- /dev/null +++ b/src/lib/components/displays/AirDisplay/utils/air-notes.ts @@ -0,0 +1,54 @@ +import type { Note } from '../interfaces/note.interface'; + +type NotesEnvelope = { + data?: unknown; + items?: unknown; + result?: unknown; +}; + +type RawAirNote = { + id?: string | number; + created_at?: string; + created_by?: string; + title?: string; + note?: string; + include_in_report?: boolean | string; + includeInReport?: boolean | string; +}; + +export function parseAirNotesResponse(payload: unknown): Note[] { + const envelope = (payload ?? {}) as NotesEnvelope; + const data = envelope.data as NotesEnvelope | undefined; + const result = envelope.result as NotesEnvelope | undefined; + const items = + [ + payload, + envelope.data, + envelope.result, + envelope.items, + data?.data, + data?.items, + result?.data, + result?.items + ].find(Array.isArray) ?? []; + + return (items as unknown[]).map((item, index) => { + const note = (item ?? {}) as RawAirNote; + const fallbackId = `${typeof note.created_at === 'string' ? note.created_at : 'note'}-${ + typeof note.note === 'string' && note.note.trim().length > 0 ? note.note : index + }`; + + return { + id: String(note.id ?? fallbackId), + created_at: typeof note.created_at === 'string' ? note.created_at : '', + created_by: typeof note.created_by === 'string' ? note.created_by : '', + title: typeof note.title === 'string' ? note.title : '', + note: typeof note.note === 'string' ? note.note : '', + includeInReport: + note.include_in_report === true || + note.include_in_report === 'true' || + note.includeInReport === true || + note.includeInReport === 'true' + }; + }); +} diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.server.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.server.ts index f4e2d3a8..1692f86e 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.server.ts +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.server.ts @@ -55,7 +55,9 @@ export const actions: Actions = { saveDataNote: async ({ request, fetch, locals }) => { const authToken = locals.jwtString ?? null; const data = await request.formData(); - const noteContent = data.get('note')?.toString() ?? ''; + const noteContent = data.get('note')?.toString().trim() ?? ''; + const noteTitle = data.get('title')?.toString().trim() ?? ''; + const includeInReport = data.get('include_in_report')?.toString() !== 'false'; const telemetryId = data.get('created_at')?.toString() ?? ''; const devEui = data.get('dev_eui')?.toString() ?? ''; @@ -74,6 +76,8 @@ export const actions: Actions = { try { await api.createAirNote({ note: noteContent, + title: noteTitle, + include_in_report: includeInReport, created_at: telemetryId, dev_eui: devEui }); 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 8a6290ab..99170d41 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte @@ -20,6 +20,7 @@ import SETTINGS_ICON from '$lib/images/icons/settings.svg'; import CsvTrafficExportDialog from './csvTrafficExportDialog.svelte'; import { onDestroy } from 'svelte'; + import NotesReviewDialog from './NotesReviewDialog.svelte'; type RangeSelection = 'today' | 24 | 48 | 72; @@ -568,6 +569,15 @@
{#if permissionLevel <= 2} + + + + import Icon from '$lib/components/Icon.svelte'; + import { + CwButton, + CwDialog, + CwCalendarScroll, + CwSwitch, + CwSearchInput, + CwDateTimeRangePicker, + type CwCalendarScrollItem, + type CwRangeDateValue + } from '@cropwatchdevelopment/cwui'; + import NOTE_ICON from '$lib/images/icons/notes_history.svg'; + import { formatDateTime } from '$lib/i18n/format'; + import { m } from '$lib/paraglide/messages.js'; + import { ApiService } from '$lib/api/api.service'; + import type { Note } from '$lib/components/displays/AirDisplay/interfaces/note.interface'; + import { parseAirNotesResponse } from '$lib/components/displays/AirDisplay/utils/air-notes'; + + interface Props { + authToken: string | null; + devEui: string; + locationName: string; + timeZone: string; + disabled?: boolean; + } + + interface DayPlan extends CwCalendarScrollItem { + entries: Note[]; + } + + let { authToken, devEui, disabled = false }: Props = $props(); + + let open = $state(false); + let loading = $state(false); + let loadError = $state(null); + let search = $state(''); + let showOnlyDatesWithNotes = $state(true); + let selectedMonth = $state(buildInitialMonthRange()); + let notes = $state([]); + let activeRequestId = 0; + + let calendarRange = $derived.by(() => getCalendarRange(selectedMonth)); + let filteredNotes = $derived.by(() => { + const query = search.trim().toLowerCase(); + if (!query) { + return notes; + } + + return notes + .map((day) => ({ + ...day, + entries: day.entries.filter((entry) => matchesSearch(entry, query)) + })) + .filter((day) => day.entries.length > 0); + }); + + function buildInitialMonthRange(): CwRangeDateValue { + const now = new Date(); + return { + start: new Date(now.getFullYear(), now.getMonth(), 1), + end: new Date(now.getFullYear(), now.getMonth() + 1, 0) + }; + } + + function getCalendarRange(value: CwRangeDateValue): { start: Date; end: Date } { + let start = new Date(value.start.getFullYear(), value.start.getMonth(), 1); + let end = new Date(value.end.getFullYear(), value.end.getMonth() + 1, 0); + + if (start.getTime() > end.getTime()) { + [start, end] = [end, start]; + } + + return { start, end }; + } + + function getMonthStarts(value: CwRangeDateValue): Date[] { + const { start, end } = getCalendarRange(value); + const months: Date[] = []; + + for ( + let cursor = new Date(start.getFullYear(), start.getMonth(), 1); + cursor.getTime() <= end.getTime(); + cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1) + ) { + months.push(new Date(cursor)); + } + + return months; + } + + function toLocalDateKey(value: string | Date): string | null { + const parsed = value instanceof Date ? value : new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + + return [ + parsed.getFullYear(), + String(parsed.getMonth() + 1).padStart(2, '0'), + String(parsed.getDate()).padStart(2, '0') + ].join('-'); + } + + function buildDayPlans(entries: Note[]): DayPlan[] { + const plansByDate: Record = {}; + + for (const entry of entries) { + const dayKey = toLocalDateKey(entry.created_at); + if (!dayKey) { + continue; + } + const existing = plansByDate[dayKey]; + + if (existing) { + existing.entries.push(entry); + continue; + } + + plansByDate[dayKey] = { + date: dayKey, + entries: [entry] + }; + } + + return Object.values(plansByDate) + .map((day) => ({ + ...day, + entries: [...day.entries].sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ) + })) + .sort((a, b) => String(a.date).localeCompare(String(b.date))); + } + + function matchesSearch(entry: Note, query: string): boolean { + return ( + entry.title.toLowerCase().includes(query) || + entry.note.toLowerCase().includes(query) || + entry.created_by.toLowerCase().includes(query) || + formatDateTime(entry.created_at).toLowerCase().includes(query) + ); + } + + function getNoteTitle(entry: Note): string { + const trimmedTitle = entry.title.trim(); + return trimmedTitle.length > 0 ? trimmedTitle : m.display_note_untitled(); + } + + function getReportVisibilityLabel(entry: Note): string { + return entry.includeInReport + ? m.display_note_report_included() + : m.display_note_report_excluded(); + } + + function getNoteMeta(entry: Note): string { + const timestamp = formatDateTime(entry.created_at, { hour: 'numeric', minute: '2-digit' }); + const author = entry.created_by.trim(); + return author ? `${timestamp} - ${author}` : timestamp; + } + + async function loadNotesData(): Promise { + if (!authToken || !devEui) { + notes = []; + loadError = null; + return; + } + + const requestId = ++activeRequestId; + loading = true; + loadError = null; + + const api = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + const monthStarts = getMonthStarts(selectedMonth); + const responses = await Promise.all( + monthStarts.map((monthStart) => + api.getAllAirNotesForMonth({ + dev_eui: devEui, + date: monthStart + }) + ) + ); + const nextNotes = buildDayPlans( + responses.flatMap((response) => parseAirNotesResponse(response)) + ); + + if (requestId !== activeRequestId) { + return; + } + + notes = nextNotes; + } catch (error) { + if (requestId !== activeRequestId) { + return; + } + + notes = []; + loadError = m.display_note_load_failed(); + console.error('Error loading notes:', error); + } finally { + if (requestId === activeRequestId) { + loading = false; + } + } + } + + const openDialog = (): void => { + open = true; + void loadNotesData(); + }; + + + + + {m.display_view_notes()} + + + +
+
+ + void loadNotesData()} + bind:value={selectedMonth} + /> + +
+ + {#if loadError} +

{loadError}

+ {/if} + + + {#snippet content(item)} + {#if item} +
+ {#each item.entries as entry (entry.id)} +
+
+
+
+

{getNoteTitle(entry)}

+

{getNoteMeta(entry)}

+
+

+ {getReportVisibilityLabel(entry)} +

+

{entry.note}

+
+ + Delete +
+
+ {/each} +
+ {:else} +

{m.display_note_no_notes_for_date()}

+ {/if} + {/snippet} + {#snippet emptyState()} + {#if loading} +

{m.display_note_loading()}

+ {:else if search.trim().length > 0} +

+ {m.display_note_no_search_results()} +

+ {:else} +

+ {m.display_note_no_notes_for_month()} +

+ {/if} + {/snippet} + +
+
+ {#snippet actions()} + (open = false)} variant="secondary"> + {m.action_close()} + + {/snippet} +
+ + diff --git a/src/routes/reports/[report_id]/edit/+page.server.ts b/src/routes/reports/[report_id]/edit/+page.server.ts index c332cc55..ac1c516b 100644 --- a/src/routes/reports/[report_id]/edit/+page.server.ts +++ b/src/routes/reports/[report_id]/edit/+page.server.ts @@ -44,6 +44,11 @@ const readOptionalInteger = (value: unknown): number | undefined => { return Number.isFinite(parsed) ? parsed : undefined; }; +const readOptionalPositiveInteger = (value: unknown): number | undefined => { + const parsed = readOptionalInteger(value); + return parsed !== undefined && parsed > 0 ? parsed : undefined; +}; + const readOptionalNumber = (value: unknown): number | undefined => { if (typeof value === 'number' && Number.isFinite(value)) { return value; @@ -304,8 +309,7 @@ function sanitizeDataProcessingScheduleEntries( if (!Array.isArray(value)) return undefined; // Validate HH:mm format - const isValidTime = (t: unknown): t is string => - typeof t === 'string' && /^\d{2}:\d{2}$/.test(t); + const isValidTime = (t: unknown): t is string => typeof t === 'string' && /^\d{2}:\d{2}$/.test(t); const entries = value .filter(isRecord) @@ -363,9 +367,11 @@ function sanitizeReportPayload( }; const createdAt = readOptionalString(payload.created_at); + const dataPullInterval = readOptionalPositiveInteger(payload.data_pull_interval); const id = readOptionalInteger(payload.id); if (createdAt) sanitized.created_at = createdAt; + if (dataPullInterval !== undefined) sanitized.data_pull_interval = dataPullInterval; if (id !== undefined) sanitized.id = id; if (reportId) sanitized.report_id = reportId; if (userId) sanitized.user_id = userId; diff --git a/src/routes/reports/[report_id]/edit/+page.svelte b/src/routes/reports/[report_id]/edit/+page.svelte index aba63320..2034a42e 100644 --- a/src/routes/reports/[report_id]/edit/+page.svelte +++ b/src/routes/reports/[report_id]/edit/+page.svelte @@ -40,6 +40,12 @@ SelectOption } from './report-form.types'; import { createReportAlertPointsEditorText } from './alert-points-editor-text'; + import { + DEFAULT_REPORT_DATA_PULL_INTERVAL, + buildReportDataPullIntervalOptions, + normalizeReportDataPullInterval, + parseReportDataPullInterval + } from './data-pull-interval'; type ReportForm = { error?: string; @@ -329,6 +335,7 @@ function buildDefaultDraft(): ReportDraft { return { created_at: '', + data_pull_interval: DEFAULT_REPORT_DATA_PULL_INTERVAL, dev_eui: normalizeDevEui(data.devEui ?? ''), id: '', name: '', @@ -395,6 +402,7 @@ return { created_at: source.created_at ?? '', + data_pull_interval: normalizeReportDataPullInterval(source.data_pull_interval), dev_eui: normalizedReportDevEui, id: source.id != null ? String(source.id) : '', name: source.name ?? '', @@ -442,6 +450,7 @@ const reportId = cleanOptional(draft.report_id); const userId = cleanOptional(draft.user_id) ?? currentUser?.id ?? undefined; const devEui = normalizeDevEui(rootDevEui); + const dataPullInterval = parseReportDataPullInterval(draft.data_pull_interval); const reportUserSchedule: CreateReportUserScheduleRequest[] = draft.report_user_schedule.map( (schedule) => { @@ -522,6 +531,7 @@ }; if (createdAt) payload.created_at = createdAt; + if (dataPullInterval !== undefined) payload.data_pull_interval = dataPullInterval; if (id !== undefined) payload.id = id; if (reportId) payload.report_id = reportId; if (userId) payload.user_id = userId; @@ -688,6 +698,9 @@ left.label.localeCompare(right.label) ); }); + let dataPullIntervalOptions = $derived.by(() => [ + ...buildReportDataPullIntervalOptions(fields.data_pull_interval) + ]); let requestPayload = $derived.by(() => buildRequestPayload(fields, normalizedSelectedDevEui, alertPointsValue) ); @@ -807,6 +820,14 @@ />
+
+ +
+ {#if !loadingDevices && deviceOptions.length === 0}

{m.reports_create_no_devices_loaded()}

diff --git a/src/routes/reports/[report_id]/edit/data-pull-interval.ts b/src/routes/reports/[report_id]/edit/data-pull-interval.ts new file mode 100644 index 00000000..e5eaad1f --- /dev/null +++ b/src/routes/reports/[report_id]/edit/data-pull-interval.ts @@ -0,0 +1,69 @@ +import type { SelectOption } from './report-form.types'; + +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/[report_id]/edit/report-form.types.ts b/src/routes/reports/[report_id]/edit/report-form.types.ts index 6f14bce8..8abf7f4f 100644 --- a/src/routes/reports/[report_id]/edit/report-form.types.ts +++ b/src/routes/reports/[report_id]/edit/report-form.types.ts @@ -43,6 +43,7 @@ export type DataProcessingScheduleDraft = { export type ReportDraft = { created_at: string; + data_pull_interval: string; dev_eui: string; id: string; name: string;