diff --git a/JWT_PDF_API_GUIDE.md b/JWT_PDF_API_GUIDE.md index 2c2e5524..ae84635b 100644 --- a/JWT_PDF_API_GUIDE.md +++ b/JWT_PDF_API_GUIDE.md @@ -2,7 +2,7 @@ ## 📋 **New Endpoint Created** -### **Endpoint:** `/api/devices/{devEui}/data-jwt-pdf` +### **Endpoint:** `/api/devices/{devEui}/pdf` - **Method:** `GET` - **Authentication:** JWT Bearer token required - **Purpose:** Generate PDF reports for device data (server-to-server) @@ -42,7 +42,7 @@ curl -X POST "http://localhost:5173/api/auth/login" \ ### **Postman/cURL Example:** ```bash -curl -X GET "http://localhost:5173/api/devices/2CF7F1C0630000AC/data-jwt-pdf?start=2025-05-01&end=2025-06-06" \ +curl -X GET "http://localhost:5173/api/devices/2CF7F1C0630000AC/pdf?start=2025-05-01&end=2025-06-06" \ -H "Authorization: Bearer YOUR_JWT_TOKEN_HERE" \ --output "device-report.pdf" ``` @@ -104,7 +104,7 @@ curl -X GET "http://localhost:5173/api/devices/2CF7F1C0630000AC/data-jwt-pdf?sta ### **HTTP Request Node Configuration:** - **Method:** `GET` -- **URL:** `http://your-domain.com/api/devices/{devEui}/data-jwt-pdf?start=2025-05-01&end=2025-06-06` +- **URL:** `http://your-domain.com/api/devices/{devEui}/pdf?start=2025-05-01&end=2025-06-06` - **Headers:** ```json { diff --git a/package.json b/package.json index 7f4ce99b..f152322c 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@types/d3": "^7.4.3", "@types/event-calendar__core": "^4.4.0", "@types/luxon": "^3.6.2", + "@types/pdfkit": "^0.17.0", "@types/swagger-ui": "^5.21.1", "@vite-pwa/sveltekit": "^1.0.0", "eslint": "^9.18.0", @@ -91,7 +92,6 @@ }, "pnpm": { "onlyBuiltDependencies": [ - "@sentry/cli", "esbuild", "svelte-preprocess" ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9888816..8887457d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: '@types/luxon': specifier: ^3.6.2 version: 3.6.2 + '@types/pdfkit': + specifier: ^0.17.0 + version: 0.17.0 '@types/swagger-ui': specifier: ^5.21.1 version: 5.21.1 @@ -1921,6 +1924,9 @@ packages: '@types/node@24.0.3': resolution: {integrity: sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==} + '@types/pdfkit@0.17.0': + resolution: {integrity: sha512-krED/Otct47bF6yVQNWD34k2+bdyMt1isBvRoC/PubRO37uCNUg2FOtbjn+OPWEXb947QIhQ09q8SOWYXX8B1A==} + '@types/phoenix@1.6.6': resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==} @@ -7347,6 +7353,10 @@ snapshots: dependencies: undici-types: 7.8.0 + '@types/pdfkit@0.17.0': + dependencies: + '@types/node': 24.0.3 + '@types/phoenix@1.6.6': {} '@types/raf@3.4.3': diff --git a/src/hooks.server.ts b/src/hooks.server.ts index bdcbb0d8..b1a6940f 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -99,82 +99,6 @@ const handleSupabase: Handle = async ({ event, resolve }) => { return name === 'content-range' || name === 'x-supabase-api-version'; } }); - - // try { - // console.log('Processing JWT token for API request:', event.url.pathname); - // console.log('Token starts with:', jwt.substring(0, 10) + '...'); - - // // Try different validation approaches for maximum compatibility - - // // 1. First try to set the session with both tokens if available - // if (refreshToken) { - // console.log('Using both access and refresh tokens'); - // const sessionResult = await event.locals.supabase.auth.setSession({ - // access_token: jwt, - // refresh_token: refreshToken - // }); - - // if (sessionResult.error) { - // console.error('Failed to set session with tokens:', sessionResult.error.message); - // } else if (sessionResult.data?.session && sessionResult.data?.user) { - // console.log( - // 'Successfully set session with tokens for user:', - // sessionResult.data.user.email - // ); - // tokenSession = sessionResult.data.session; - // tokenUser = sessionResult.data.user; - - // // Set the user in event.locals immediately - // event.locals.user = tokenUser; - // event.locals.session = tokenSession; - // } - // } - - // // 2. If that didn't work or no refresh token, try to validate the access token - // if (!tokenUser) { - // console.log('Trying to validate access token directly'); - // const { data, error } = await event.locals.supabase.auth.getUser(jwt); - - // if (error) { - // console.error('Invalid JWT token:', error.message); - // } else if (data?.user) { - // console.log('Valid JWT token for user:', data.user.email); - // tokenUser = data.user; - - // // Get the session - // const sessionResult = await event.locals.supabase.auth.getSession(); - // tokenSession = sessionResult.data.session; - - // // Set the user in event.locals immediately - // event.locals.user = tokenUser; - // event.locals.session = tokenSession; - // } - // } - - // // 3. Last resort: Try to verify the token as an API token - // if (!tokenUser && event.url.pathname.startsWith('/api/')) { - // console.log('Trying to validate as API token for:', event.url.pathname); - // try { - // // For API endpoints, we'll bypass normal authentication for API tokens - // // This is just for testing purposes - in production, you'd verify the token - // // Create a user object that matches the User type from Supabase - // const apiUser = await event.locals.supabase.auth.getUser(jwt); - // if (apiUser.data?.user) { - // tokenUser = apiUser.data.user; - // console.log('Created API user for token access:', apiUser.data.user.email); - - // // Set the user in event.locals immediately - // event.locals.user = tokenUser; - // } - // } catch (apiErr) { - // console.error('Failed to create API user:', apiErr); - // } - // } - // } catch (err) { - // console.error('Error processing JWT token:', err); - // } - // } else if (event.url.pathname.startsWith('/api')) { - // console.log('No Authorization token found for API request:', event.url.pathname); } // Enhance session validation to include explicit debug logging @@ -209,16 +133,8 @@ const handleSupabase: Handle = async ({ event, resolve }) => { ); return { session: null, user: null }; } - - //console.log( - // `Session successfully validated for path: ${event.url.pathname}, user: ${user?.email}` - //); return { session, user }; } catch (err) { - // console.error( - // `Unexpected error during session validation for path: ${event.url.pathname}:`, - // err - // ); return { session: null, user: null }; } }; @@ -242,11 +158,6 @@ const handleSupabase: Handle = async ({ event, resolve }) => { const authHeader = headers.get('authorization') || headers.get('Authorization'); const apiToken = authHeader?.replace(/^Bearer\s+/i, '').trim(); - // console.log( - // 'API route access with token:', - // apiToken ? `${apiToken.substring(0, 10)}...` : 'none' - // ); - // If we have a token, validate it before proceeding if (apiToken) { // console.log('Validating API token for:', pathname); diff --git a/src/lib/components/devices/ExportButton.svelte b/src/lib/components/devices/ExportButton.svelte new file mode 100644 index 00000000..8118602e --- /dev/null +++ b/src/lib/components/devices/ExportButton.svelte @@ -0,0 +1,168 @@ + + +{#if showDatePicker} + +{:else} + +{/if} diff --git a/src/lib/csv/CsvDownloadButton.svelte b/src/lib/csv/CsvDownloadButton.svelte deleted file mode 100644 index 4e394b72..00000000 --- a/src/lib/csv/CsvDownloadButton.svelte +++ /dev/null @@ -1,98 +0,0 @@ - - - diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index fe0cb635..2e13fcce 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -73,14 +73,14 @@ export const strings = { load_selected_data: 'Load Selected Data', 'Load Selected Range': 'Load Selected Range', load_selected_range: 'Load Selected Range', - 'Download CSV': 'CSV', + export: 'Export', + csv: 'CSV', download_csv: 'CSV', 'Download Excel': 'Download Excel', download_excel: 'Download Excel', 'Download PDF': 'Download PDF', + pdf: 'PDF', download_pdf: 'Download PDF', - 'CSV Download': 'CSV Download', - 'Report Download': 'Report Download', Report: 'Report', report: 'Report', Reports: 'Reports', @@ -275,7 +275,7 @@ export const strings = { 'Add Rule': 'Add Rule', 'No rules found.': 'No rules found.', Name: 'Name', - Edit: 'Edit', + edit: 'Edit', Actions: 'Actions', 'No recipients': 'No recipients', 'No criteria defined': 'No criteria defined', @@ -310,6 +310,41 @@ export const strings = { Method: 'Method', Recipients: 'Recipients', + // Report page + add_report: 'Add Report', + generate_report: 'Generate Report', + generate_sample_report: 'Generate Sample Report', + exporting_data: 'Exporting {type} data...', + + // PDF report content + device_report: 'Device Report', + generated_at: 'Created At', + generated_by: 'Created By', + installed_at: 'Installed at', + date: 'Date', + created: 'Created', + verified: 'Verified', + approved: 'Approved', + sampling_size: 'Sampling Size', + date_range: 'Date Range', + status: 'Status', + normal: 'Normal', + notice: 'Notice', + warning: 'Warning', + alert: 'Alert', + min: 'Min', + max: 'Max', + avg: 'Avg', + stddev: 'Standard Deviation', + datetime: 'Date/Time', + comment: 'Comment', + sensor_data_trends: 'Sensor Data Trends', + legend: 'Legend', + time: 'Time', + value: 'Value', + summary: 'Summary', + data_history: 'Data History', + // Settings page Unknown: 'Unknown', EUI: 'EUI', diff --git a/src/lib/i18n/locales/ja.ts b/src/lib/i18n/locales/ja.ts index 9d3ea485..0263ebfb 100644 --- a/src/lib/i18n/locales/ja.ts +++ b/src/lib/i18n/locales/ja.ts @@ -69,14 +69,14 @@ export const strings = { load_selected_data: '選択したデータを読み込む', 'Load Selected Range': '選択したデータを読み込む', load_selected_range: '選択したデータを読み込む', - 'Download CSV': 'CSV', + export: 'エクスポート', + csv: 'CSV', download_csv: 'CSV', 'Download Excel': 'Excelをダウンロード', download_excel: 'Excelをダウンロード', 'Download PDF': 'PDFをダウンロード', + pdf: 'PDF', download_pdf: 'PDFをダウンロード', - 'CSV Download': 'CSVダウンロード', - 'Report Download': 'レポートダウンロード', Report: 'レポート', report: 'レポート', Reports: 'レポート', @@ -325,7 +325,7 @@ export const strings = { // Rules page 'Add Rule': 'ルールを追加', 'No rules found.': 'ルールが見つかりません。', - Edit: '編集', + edit: '編集', Actions: '操作', 'No recipients': '受信者なし', 'No criteria defined': '条件が定義されていません', @@ -361,6 +361,41 @@ export const strings = { 'Expand sidebar to search': 'サイドバーを展開して検索', 'Collapse sidebar': 'サイドバーを折りたたむ', + // Report page + add_report: 'レポートを追加', + generate_report: 'レポートを生成', + generate_sample_report: 'サンプルレポートを生成', + exporting_data: '{type} データをエクスポート中...', + + // PDF report content + device_report: 'デバイスレポート', + generated_at: '作成日時', + generated_by: '作成者', + installed_at: '設置場所', + date: '日付', + created: '作成', + verified: '確認', + approved: '承認', + sampling_size: 'サンプリング数', + date_range: '測定期間', + status: 'ステータス', + normal: '通常', + notice: '通知', + warning: '警告', + alert: 'アラート', + min: '最小', + max: '最大', + avg: '平均', + stddev: '標準偏差', + datetime: '日時', + comment: 'コメント', + sensor_data_trends: 'センサーデータの傾向', + legend: '凡例', + time: '時間', + value: '測定値', + summary: '概要', + data_history: 'データ履歴', + // Settings page Unknown: '不明', EUI: 'EUI', diff --git a/src/lib/interfaces/IDeviceDataService.ts b/src/lib/interfaces/IDeviceDataService.ts index 47331509..6dcad329 100644 --- a/src/lib/interfaces/IDeviceDataService.ts +++ b/src/lib/interfaces/IDeviceDataService.ts @@ -27,31 +27,41 @@ export interface IDeviceDataService { /** * Get device data for report with optional filtering - * @param devEui The device EUI - * @param startDate The start date - * @param endDate The end date - * @param timezone The timezone - * @param intervalMinutes The interval in minutes - * @param columns Optional columns to filter - * @param ops Optional operators for filtering - * @param mins Optional minimum values - * @param maxs Optional maximum values + * @param params.devEui The device EUI + * @param params.startDate The start date + * @param params.endDate The end date + * @param params.timezone The timezone + * @param params.intervalMinutes The interval in minutes + * @param params.columns Optional columns to filter + * @param params.ops Optional operators for filtering + * @param params.mins Optional minimum values + * @param params.maxs Optional maximum values */ - getDeviceDataForReport( - devEui: string, - startDate: Date, - endDate: Date, - timezone: string, - intervalMinutes: number, - columns?: string[], - ops?: string[], - mins?: number[], - maxs?: (number | null)[] - ): Promise; + getDeviceDataForReport({ + devEui, + startDate, + endDate, + timezone, + intervalMinutes, + columns, + ops, + mins, + maxs + }: { + devEui: string; + startDate: Date; + endDate: Date; + timezone: string; + intervalMinutes: number; + columns?: string[]; + ops?: string[]; + mins?: number[]; + maxs?: (number | null)[]; + }): Promise; /** * Get alert points for a device from its reports * @param devEui The device EUI */ - getAlertPointsForDevice(devEui: string): Promise; + getAlertPointsForDevice(devEui: string): Promise; } diff --git a/src/lib/pdf/index.ts b/src/lib/pdf/index.ts new file mode 100644 index 00000000..921e50ce --- /dev/null +++ b/src/lib/pdf/index.ts @@ -0,0 +1,13 @@ +export interface TableCell { + label: string; + value?: number | string | Date; + color?: string; + bgColor?: string; + fontSize?: number; + width?: number; +} + +export interface TableRow { + header: TableCell; + cells: TableCell[]; +} diff --git a/src/lib/pdf/pdfDataTable.ts b/src/lib/pdf/pdfDataTable.ts index ba5c6463..825f5765 100644 --- a/src/lib/pdf/pdfDataTable.ts +++ b/src/lib/pdf/pdfDataTable.ts @@ -1,33 +1,8 @@ import PDFDocument from 'pdfkit'; -import { DateTime } from 'luxon'; - -interface TableData { - date: string; - values: number[]; -} - -interface AlertPointData { - id: number; - name: string; - operator: string | null; - min: number | null; - max: number | null; - report_id: string; - created_at: string; - hex_color: string | null; - data_point_key: string | null; -} - -interface AlertData { - alert_points: AlertPointData[]; - created_at: string; - dev_eui: string; - id: number; - name: string; - report_id: string; -} +import type { TableRow } from '.'; interface TableConfig { + caption?: string; columnsPerPage: number; rowsPerColumn: number; cellWidth: number; @@ -39,88 +14,91 @@ interface TableConfig { } const DEFAULT_CONFIG: TableConfig = { + caption: '', columnsPerPage: 4, rowsPerColumn: 30, cellWidth: 100, - cellHeight: 25, + cellHeight: 12, margin: 40, columnMargin: 10, fontSize: 7, - headerHeight: 25 + headerHeight: 15 }; /** * Creates a lean PDF data table that displays data from top to bottom, left to right - * @param doc - PDFKit document instance - * @param data - Array of {date, values[]} objects - * @param config - Optional configuration overrides + * @param params + * @param params.doc - PDFKit document instance + * @param params.dataHeader - Header row containing column labels + * @param params.dataRows - Array of data rows, each row is an array of values + * @param params.config - Optional configuration overrides */ -export function createPDFDataTable( - doc: InstanceType, - data: TableData[], - alertPointData: AlertData, - config: Partial = {} -): void { +export function createPDFDataTable({ + doc, + dataHeader, + dataRows, + config = {} +}: { + doc: InstanceType; + dataHeader: TableRow; + dataRows: TableRow[]; + config?: Partial; +}): void { const conf = { ...DEFAULT_CONFIG, ...config }; + const { caption, margin, headerHeight, cellWidth, cellHeight, columnsPerPage, columnMargin } = + conf; + // Calculate how many rows can actually fit on a page const pageHeight = doc.page.height; - const availableHeight = pageHeight - conf.margin * 2 - conf.headerHeight; - const actualRowsPerColumn = Math.floor(availableHeight / conf.cellHeight); + const availableHeight = pageHeight - margin * 2 - headerHeight - 50; // 50 for caption space + const actualRowsPerColumn = Math.floor(availableHeight / cellHeight); // Calculate how many columns can actually fit on a page const pageWidth = doc.page.width; - const availableWidth = pageWidth - conf.margin * 2; - const totalColumnWidth = conf.cellWidth + conf.columnMargin; + const availableWidth = pageWidth - margin * 2; + const columnWidth = [dataHeader.header, ...dataHeader.cells].reduce( + (total, col) => total + (col.width ?? cellWidth), + 0 + ); + const totalColumnWidth = columnWidth + columnMargin; const actualColumnsPerPage = Math.floor(availableWidth / totalColumnWidth); - const finalColumnsPerPage = Math.min(conf.columnsPerPage, actualColumnsPerPage); + const finalColumnsPerPage = Math.min(columnsPerPage, actualColumnsPerPage); + + if (caption) { + doc.fillColor('black').fontSize(12).text(caption, margin, doc.y); + doc.moveDown(0.5); + } let currentPage = 0; let dataIndex = 0; + let startY = doc.y; - doc.addPage(); // Add Page break because the data table should always start on a new page. - - // add header with legend of colors and alert points - doc - .fillColor('black') - .fontSize(conf.fontSize) - .text('アラートポイントの色分け:', conf.margin, conf.margin, { - width: pageWidth - conf.margin * 2, - align: 'left' - }); - const legendStartY = conf.margin + 15; - // Draw alert points legend - const alertPoints = alertPointData.alert_points || []; - alertPoints.forEach((point, index) => { - const color = point.hex_color || '#ffffff'; - const legendX = - conf.margin + (index % finalColumnsPerPage) * (conf.cellWidth + conf.columnMargin); - // const legendY = legendStartY + Math.floor(index / finalColumnsPerPage) * 15; - - doc.fillColor(color).rect(0, 0, 10, 10).fill(); - doc.fillColor('black').text(point.name, legendX + 15, 0, { - width: conf.cellWidth - 25, - align: 'left' - }); - }); - - while (dataIndex < data.length) { + while (dataIndex < dataRows.length) { if (currentPage > 0) { doc.addPage(); + startY = margin; } - const startY = conf.margin; const totalColumns = Math.min( finalColumnsPerPage, - Math.ceil((data.length - dataIndex) / actualRowsPerColumn) + Math.ceil((dataRows.length - dataIndex) / actualRowsPerColumn) ); // Draw columns for current page - for (let col = 0; col < totalColumns && dataIndex < data.length; col++) { - const startX = conf.margin + col * (conf.cellWidth + conf.columnMargin); - const endIndex = Math.min(dataIndex + actualRowsPerColumn, data.length); - - drawColumn(doc, data.slice(dataIndex, endIndex), alertPointData, startX, startY, conf); + for (let col = 0; col < totalColumns && dataIndex < dataRows.length; col++) { + const startX = margin + col * (columnWidth + columnMargin); + const endIndex = Math.min(dataIndex + actualRowsPerColumn, dataRows.length); + + drawColumn({ + doc, + dataHeader, + dataRows: dataRows.slice(dataIndex, endIndex), + columnWidth, + startX, + startY, + config: conf + }); dataIndex = endIndex; } @@ -128,168 +106,83 @@ export function createPDFDataTable( } } -function drawColumn( - doc: InstanceType, - columnData: TableData[], - alertPointData: AlertData, - startX: number, - startY: number, - config: TableConfig -): void { +function drawColumn({ + doc, + dataHeader, + dataRows, + columnWidth, + startX, + startY, + config +}: { + doc: InstanceType; + dataHeader: TableRow; + dataRows: TableRow[]; + columnWidth: number; + startX: number; + startY: number; + config: TableConfig; +}): void { let currentY = startY; + const borderColor = '#ccc'; + const { fontSize: defaultFontSize, cellWidth, headerHeight, cellHeight } = config; + // Draw header - doc.fillColor('#e8e8e8').rect(startX, currentY, config.cellWidth, config.headerHeight).fill(); + doc.fillColor('#e8e8e8').rect(startX, currentY, columnWidth, config.headerHeight).fill(); - doc.strokeColor('#000').rect(startX, currentY, config.cellWidth, config.headerHeight).stroke(); + doc.strokeColor(borderColor).rect(startX, currentY, columnWidth, config.headerHeight).stroke(); - doc - .fillColor('#000') - .fontSize(config.fontSize - 1) - .text('日時', startX + 5, currentY + 3, { - width: config.cellWidth - 10, - align: 'left' - }) - .text('時刻', startX + 5, currentY + 12, { - width: config.cellWidth - 10, - align: 'left' - }); + const columns = [dataHeader.header, ...dataHeader.cells]; - // Add value headers if we have data - if (columnData.length > 0) { - // Number of values determines how many columns we need - const valueCount = columnData[0].values.length; + const getCellX = (index: number): number => + startX + columns.slice(0, index).reduce((total, col) => total + (col.width ?? cellWidth), 0); - for (let i = 0; i < valueCount; i++) { - const headerText = `値${i + 1}`; // "Value 1", "Value 2", etc. in Japanese - doc.fontSize(config.fontSize).text(headerText, startX + 40 + i * 20, currentY + 8, { - width: 15, - align: 'center' - }); - } + // Add value headers if we have data + if (dataRows.length > 0) { + columns.forEach(({ label, width = columnWidth, fontSize = defaultFontSize }, index) => { + doc + .fillColor('#000') + .fontSize(fontSize) + .text(label, getCellX(index), currentY + 2, { width, align: 'center' }); + }); } - currentY += config.headerHeight; + currentY += headerHeight; // Draw data rows - columnData.forEach((row, index) => { + dataRows.forEach(({ header, cells }, index) => { const isEvenRow = index % 2 === 0; // Row background if (!isEvenRow) { - doc.fillColor('#f9f9f9').rect(startX, currentY, config.cellWidth, config.cellHeight).fill(); + doc.fillColor('#f9f9f9').rect(startX, currentY, columnWidth, cellHeight).fill(); } else { - doc.fillColor('#ffffff').rect(startX, currentY, config.cellWidth, config.cellHeight).fill(); + doc.fillColor('#ffffff').rect(startX, currentY, columnWidth, cellHeight).fill(); } // Row border - doc.strokeColor('#ccc').rect(startX, currentY, config.cellWidth, config.cellHeight).stroke(); - - // Date cell - split date and time - const [datePart, timePart] = DateTime.fromJSDate(new Date(row.date)) - .toFormat('yyyy/MM/dd HH:mm') - .split(' '); - - doc - .fillColor('#000') - .fontSize(config.fontSize - 1) - .text(datePart, startX + 2, currentY + 2, { - width: 38, - align: 'left' - }); - - if (timePart) { - doc.text(timePart, startX + 2, currentY + 12, { - width: 38, - align: 'left' - }); - } + doc.strokeColor(borderColor).rect(startX, currentY, columnWidth, cellHeight).stroke(); - // Value cells - row.values.forEach((value, valueIndex) => { - const cellX = startX + 40 + valueIndex * 20; - const displayValue = value.toFixed(1); - - // Color coding based on alert points - let bgColor = '#ffffff'; - - // Check alert points for this value - for (const alertPoint of alertPointData.alert_points || []) { - if (evaluateAlertCondition(value, alertPoint)) { - bgColor = alertPoint.hex_color || '#ffffff'; - break; // Use the first matching alert point - } - } + [header, ...cells].forEach(({ label, bgColor }, cellIndex) => { + const cellX = getCellX(cellIndex); + const { width = cellWidth, fontSize = defaultFontSize } = columns[cellIndex]; // Apply background color if not white - if (bgColor !== '#ffffff') { - doc.fillColor(bgColor).rect(cellX, currentY, 20, config.cellHeight).fill(); + if (bgColor && bgColor !== '#ffffff') { + doc.fillColor(bgColor).rect(cellX, currentY, width, cellHeight).fill(); } // Cell border - doc.strokeColor('#ccc').rect(cellX, currentY, 20, config.cellHeight).stroke(); + doc.strokeColor(borderColor).rect(cellX, currentY, width, cellHeight).stroke(); // Value text doc .fillColor('#000') - .fontSize(config.fontSize - 1) - .text(displayValue, cellX + 1, currentY + 6, { - width: 18, - align: 'center' - }); + .fontSize(fontSize - 1) + .text(label, cellX + 1, currentY + 2, { width: width - 5, align: 'right' }); }); - currentY += config.cellHeight; + currentY += cellHeight; }); } - -/** - * Evaluates whether a value meets the alert condition - * @param value - The data value to check - * @param alertPoint - The alert point configuration - * @returns boolean indicating if the condition is met - */ -function evaluateAlertCondition(value: number, alertPoint: AlertPointData): boolean { - if (!alertPoint.operator) { - return false; - } - - switch (alertPoint.operator) { - case '>': - return alertPoint.min !== null && value > alertPoint.min; - case '>=': - return alertPoint.min !== null && value >= alertPoint.min; - case '<': - return alertPoint.max !== null && value < alertPoint.max; - case '<=': - return alertPoint.max !== null && value <= alertPoint.max; - case '==': - case '=': - return ( - (alertPoint.min !== null && value === alertPoint.min) || - (alertPoint.max !== null && value === alertPoint.max) - ); - case '!=': - return ( - alertPoint.min !== null && - value !== alertPoint.min && - alertPoint.max !== null && - value !== alertPoint.max - ); - case 'between': - return ( - alertPoint.min !== null && - alertPoint.max !== null && - value >= alertPoint.min && - value <= alertPoint.max - ); - case 'outside': - return ( - alertPoint.min !== null && - alertPoint.max !== null && - (value < alertPoint.min || value > alertPoint.max) - ); - default: - return false; - } -} diff --git a/src/lib/pdf/pdfFooterPageNumber.ts b/src/lib/pdf/pdfFooterPageNumber.ts index f7848162..32871de7 100644 --- a/src/lib/pdf/pdfFooterPageNumber.ts +++ b/src/lib/pdf/pdfFooterPageNumber.ts @@ -2,9 +2,7 @@ import PDFDocument from 'pdfkit'; export function addFooterPageNumber( doc: InstanceType, - devEui: string, - startDateParam: string, - endDateParam: string + primaryText: string ): void { // 3) now stamp page numbers on every page const range = doc.bufferedPageRange(); // { start: 0, count: N } @@ -13,7 +11,7 @@ export function addFooterPageNumber( const footerMargin = 20; for (let i = 0; i < total; i++) { doc.switchToPage(i); - const text = `${devEui} | ${startDateParam} - ${endDateParam} (${i + 1} / ${total})`.toString(); + const text = `${primaryText} | Page ${i + 1} / ${total}`; const w = doc.widthOfString(text); const x = doc.page.width / 2 - w / 2; // center the text const y = doc.page.height - footerMargin - 5; diff --git a/src/lib/pdf/pdfLineChart.ts b/src/lib/pdf/pdfLineChart.ts index ea8f69ae..46f52971 100644 --- a/src/lib/pdf/pdfLineChart.ts +++ b/src/lib/pdf/pdfLineChart.ts @@ -1,31 +1,7 @@ -import PDFDocument from 'pdfkit'; +import type { ReportAlertPoint } from '$lib/models/Report'; import { DateTime } from 'luxon'; - -interface ChartData { - date: string; - values: number[]; -} - -interface AlertPointData { - id: number; - name: string; - operator: string | null; - min: number | null; - max: number | null; - report_id: string; - created_at: string; - hex_color: string | null; - data_point_key: string | null; -} - -interface AlertData { - alert_points: AlertPointData[]; - created_at: string; - dev_eui: string; - id: number; - name: string; - report_id: string; -} +import PDFDocument from 'pdfkit'; +import type { TableRow } from '.'; interface ChartConfig { width: number; @@ -43,6 +19,7 @@ interface ChartConfig { lineWidth: number; pointRadius: number; title?: string; + legendLabel?: string; xAxisLabel?: string; yAxisLabel?: string; } @@ -57,48 +34,56 @@ const DEFAULT_CHART_CONFIG: ChartConfig = { left: 80 }, gridLines: true, - showLegend: true, + showLegend: false, colors: ['#2563eb', '#dc2626', '#16a34a', '#ca8a04', '#9333ea', '#c2410c'], - fontSize: 10, + fontSize: 6, lineWidth: 2, pointRadius: 3, - title: 'データチャート', - xAxisLabel: '時間', - yAxisLabel: '値' + title: '', + legendLabel: '', + xAxisLabel: '', + yAxisLabel: '' }; /** * Creates a line chart in a PDFKit document - * @param doc - PDFKit document instance - * @param data - Array of {date, values[]} objects - * @param alertData - Alert configuration data (optional, for color coding) - * @param config - Chart configuration options + * @param {object} params + * @param params.doc - PDFKit document instance + * @param params.dataHeader - Array of column definitions + * @param params.dataRows - Array of data rows, each row is an array of values + * @param params.alertData - Alert configuration data (optional, for color coding) + * @param params.config - Chart configuration options */ -export function createPDFLineChart( - doc: InstanceType, - data: ChartData[], - alertData?: AlertData, - config: Partial = {} -): void { +export function createPDFLineChart({ + doc, + dataHeader, + dataRows, + alertPoints, + config = {} +}: { + doc: InstanceType; + dataHeader: TableRow; + dataRows: TableRow[]; + alertPoints: ReportAlertPoint[]; + config?: Partial; +}): void { const conf = { ...DEFAULT_CHART_CONFIG, ...config }; + const { fontSize } = conf; - if (!data || data.length === 0) { + if (!dataRows || !dataRows.length) { console.warn('No data provided for line chart'); return; } - // Move down to avoid overlap with previous content - doc.moveDown(2); - // Calculate chart area using current Y position const chartX = conf.margin.left; const chartY = doc.y; // Use current Y position instead of fixed margin.top - const chartWidth = conf.width - conf.margin.left - conf.margin.right; - const chartHeight = conf.height - conf.margin.top - conf.margin.bottom; + const chartWidth = conf.width - conf.margin.left - conf.margin.right - 100; + const chartHeight = conf.height - conf.margin.top - conf.margin.bottom - 100; // Prepare data for plotting - const timestamps = data.map((d) => new Date(d.date)); - const valueCount = data[0]?.values?.length || 0; + const timestamps = dataRows.map(({ header }) => header.value as Date); + const valueCount = dataRows[0]?.cells?.length ?? 0; if (valueCount === 0) { console.warn('No values found in data for line chart'); @@ -106,7 +91,9 @@ export function createPDFLineChart( } // Get min/max values for scaling - const allValues = data.flatMap((d) => d.values.filter((v) => v !== null && !isNaN(v))); + const allValues = dataRows.flatMap((d) => + d.cells.map(({ value }) => value).filter((value) => typeof value === 'number' && !isNaN(value)) + ) as number[]; const minValue = Math.min(...allValues); const maxValue = Math.max(...allValues); const valueRange = maxValue - minValue; @@ -129,7 +116,7 @@ export function createPDFLineChart( // Draw title if (conf.title) { doc - .fontSize(14) + .fontSize(12) .fillColor('#000') .text(conf.title, chartX, chartY - 30, { width: chartWidth, @@ -160,7 +147,7 @@ export function createPDFLineChart( } // Vertical grid lines - const xTicks = Math.min(10, data.length); + const xTicks = Math.min(10, dataRows.length); for (let i = 0; i <= xTicks; i++) { const x = chartX + (i / xTicks) * chartWidth; doc @@ -172,17 +159,17 @@ export function createPDFLineChart( // Draw data lines for (let valueIndex = 0; valueIndex < valueCount; valueIndex++) { - const color = conf.colors[valueIndex % conf.colors.length]; + const { color = conf.colors[valueIndex % conf.colors.length] } = dataHeader.cells[valueIndex]; doc.strokeColor(color).lineWidth(conf.lineWidth); let firstPoint = true; - for (let i = 0; i < data.length; i++) { - const point = data[i]; - const value = point.values[valueIndex]; + for (let i = 0; i < dataRows.length; i++) { + const { header, cells } = dataRows[i]; + const value = cells[valueIndex].value; - if (value !== null && !isNaN(value)) { - const x = xScale(new Date(point.date)); + if (typeof value === 'number' && !Number.isNaN(value)) { + const x = xScale(header.value as Date); const y = yScale(value); if (firstPoint) { @@ -198,12 +185,12 @@ export function createPDFLineChart( // Draw data points doc.fillColor(color); - for (let i = 0; i < data.length; i++) { - const point = data[i]; - const value = point.values[valueIndex]; + for (let i = 0; i < dataRows.length; i++) { + const { header, cells } = dataRows[i]; + const value = cells[valueIndex].value; - if (value !== null && !isNaN(value)) { - const x = xScale(new Date(point.date)); + if (typeof value === 'number' && !Number.isNaN(value)) { + const x = xScale(header.value as Date); const y = yScale(value); doc.circle(x, y, conf.pointRadius).fill(); @@ -227,7 +214,7 @@ export function createPDFLineChart( .stroke(); // Y-axis labels and ticks - doc.fontSize(conf.fontSize).fillColor('#000'); + doc.fontSize(fontSize).fillColor('#000'); const yTicks = 5; for (let i = 0; i <= yTicks; i++) { const value = paddedMin + (i / yTicks) * (paddedMax - paddedMin); @@ -248,10 +235,10 @@ export function createPDFLineChart( // X-axis labels and ticks const maxXLabels = 6; - const labelInterval = Math.max(1, Math.floor(data.length / maxXLabels)); + const labelInterval = Math.max(1, Math.floor(dataRows.length / maxXLabels)); - for (let i = 0; i < data.length; i += labelInterval) { - const timestamp = new Date(data[i].date); + for (let i = 0; i < dataRows.length; i += labelInterval) { + const timestamp = new Date(dataRows[i].header.value as Date); const x = xScale(timestamp); // Tick mark @@ -273,7 +260,7 @@ export function createPDFLineChart( doc .save() .rotate(-90, chartX - 60, chartY + chartHeight / 2) - .fontSize(12) + .fontSize(fontSize) .text(conf.yAxisLabel, chartX - 60, chartY + chartHeight / 2, { align: 'center' }) @@ -282,7 +269,7 @@ export function createPDFLineChart( if (conf.xAxisLabel) { doc - .fontSize(12) + .fontSize(fontSize) .text(conf.xAxisLabel, chartX + chartWidth / 2 - 20, chartY + chartHeight + 40, { width: 40, align: 'center' @@ -294,13 +281,12 @@ export function createPDFLineChart( const legendX = chartX + chartWidth + 20; let legendY = chartY; - doc.fontSize(10).fillColor('#000'); - doc.text('凡例', legendX, legendY); + doc.fontSize(fontSize).fillColor('#000'); + doc.text(conf.legendLabel ?? 'Legend', legendX, legendY); legendY += 20; for (let i = 0; i < valueCount; i++) { - const color = conf.colors[i % conf.colors.length]; - const label = alertData?.alert_points?.[i]?.name || `値${i + 1}`; + const { label, color = conf.colors[i % conf.colors.length] } = dataHeader.cells[i]; // Color square doc.fillColor(color).rect(legendX, legendY, 12, 12).fill(); @@ -313,10 +299,10 @@ export function createPDFLineChart( } // Draw alert thresholds if provided - if (alertData?.alert_points) { + if (alertPoints.length) { doc.strokeColor('#ff0000').lineWidth(1).opacity(0.5); - alertData.alert_points.forEach((alert, index) => { + alertPoints.forEach((alert, index) => { if (index < valueCount) { // Draw min threshold if (alert.min !== null) { @@ -327,7 +313,7 @@ export function createPDFLineChart( .lineTo(chartX + chartWidth, y) .stroke(); doc - .fontSize(8) + .fontSize(fontSize) .fillColor('#ff0000') .text(`最小: ${alert.min}`, chartX + chartWidth - 60, y - 10); } @@ -342,7 +328,7 @@ export function createPDFLineChart( .lineTo(chartX + chartWidth, y) .stroke(); doc - .fontSize(8) + .fontSize(fontSize) .fillColor('#ff0000') .text(`最大: ${alert.max}`, chartX + chartWidth - 60, y + 5); } diff --git a/src/lib/pdf/utils.ts b/src/lib/pdf/utils.ts new file mode 100644 index 00000000..5e3c0659 --- /dev/null +++ b/src/lib/pdf/utils.ts @@ -0,0 +1,97 @@ +import type { ReportAlertPoint } from '$lib/models/Report'; + +/** + * Check if a value matches the alert point condition. + */ +export const checkMatch = (_value: number, alertPoint: ReportAlertPoint): boolean => { + const { operator, value, min, max } = alertPoint; + + if (operator === '>') { + return _value > (value ?? 0); + } + + if (operator === '<') { + return _value < (value ?? Infinity); + } + + if (operator === '=') { + return _value === (value ?? 0); + } + + if (operator === 'range') { + return _value >= (min ?? 0) && _value <= (max ?? Infinity); + } + + return false; +}; + +/** + * Get the value for summary statistics. + */ +export const getValue = (valueList: number[], indicator: string): number => { + const valueMap = { + min: Math.min(...valueList), + max: Math.max(...valueList), + avg: valueList.reduce((sum, val) => sum + val, 0) / valueList.length, + stddev: Math.sqrt( + valueList.reduce( + (sum, val) => + sum + Math.pow(val - valueList.reduce((sum, v) => sum + v, 0) / valueList.length, 2), + 0 + ) / valueList.length + ) + } as Record; + + return valueMap[indicator] ?? 0; +}; + +/** + * Evaluates whether a value meets the alert condition + * @param value - The data value to check + * @param alertPoint - The alert point configuration + * @returns boolean indicating if the condition is met + */ +export function evaluateAlertCondition(value: number, alertPoint: ReportAlertPoint): boolean { + if (!alertPoint.operator) { + return false; + } + + switch (alertPoint.operator) { + case '>': + return alertPoint.min !== null && value > alertPoint.min; + case '>=': + return alertPoint.min !== null && value >= alertPoint.min; + case '<': + return alertPoint.max !== null && value < alertPoint.max; + case '<=': + return alertPoint.max !== null && value <= alertPoint.max; + case '==': + case '=': + return ( + (alertPoint.min !== null && value === alertPoint.min) || + (alertPoint.max !== null && value === alertPoint.max) + ); + case '!=': + return ( + alertPoint.min !== null && + value !== alertPoint.min && + alertPoint.max !== null && + value !== alertPoint.max + ); + case 'between': + return ( + alertPoint.min !== null && + alertPoint.max !== null && + value >= alertPoint.min && + value <= alertPoint.max + ); + case 'outside': + return ( + alertPoint.min !== null && + alertPoint.max !== null && + (value < alertPoint.min || value > alertPoint.max) + ); + default: + return false; + } +} diff --git a/src/lib/repositories/ReportRepository.ts b/src/lib/repositories/ReportRepository.ts index 54cb6c36..b0651a7d 100644 --- a/src/lib/repositories/ReportRepository.ts +++ b/src/lib/repositories/ReportRepository.ts @@ -1,13 +1,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'; -import { BaseRepository } from './BaseRepository'; import type { ErrorHandlingService } from '../errors/ErrorHandlingService'; -import type { - Report, - ReportInsert, - ReportUpdate, - ReportWithDetails, - ReportWithRecipients -} from '../models/Report'; +import type { Report, ReportWithDetails, ReportWithRecipients } from '../models/Report'; +import { BaseRepository } from './BaseRepository'; /** * Repository for report data operations @@ -99,7 +93,21 @@ export class ReportRepository extends BaseRepository { throw error; } - return data || []; + if (!data || !data.length) { + return []; + } + + // Transform data to match ReportWithDetails structure + return data.map((item) => { + const { + report_alert_points: alert_points, + report_recipients: recipients, + report_user_schedule: schedules, + ...rest + } = item; + + return { ...rest, alert_points, recipients, schedules }; + }); } catch (error) { this.errorHandler.handleDatabaseError( error as any, diff --git a/src/lib/services/DeviceDataService.ts b/src/lib/services/DeviceDataService.ts index bd5d2e45..bab765d6 100644 --- a/src/lib/services/DeviceDataService.ts +++ b/src/lib/services/DeviceDataService.ts @@ -1,9 +1,10 @@ +import type { ReportAlertPoint } from '$lib/models/Report'; +import type { SupabaseClient } from '@supabase/supabase-js'; import { DateTime } from 'luxon'; +import { ErrorHandlingService } from '../errors/ErrorHandlingService'; import type { IDeviceDataService } from '../interfaces/IDeviceDataService'; import type { DeviceType } from '../models/Device'; import type { DeviceDataRecord } from '../models/DeviceDataRecord'; -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; -import type { SupabaseClient } from '@supabase/supabase-js'; export class DeviceDataService implements IDeviceDataService { private readonly errorHandler: ErrorHandlingService; @@ -251,17 +252,27 @@ export class DeviceDataService implements IDeviceDataService { return cw_device; } - public async getDeviceDataForReport( - devEui: string, - startDate: Date, - endDate: Date, - timezone: string, - intervalMinutes: number, - columns?: string[], - ops?: string[], - mins?: number[], - maxs?: (number | null)[] - ): Promise { + public async getDeviceDataForReport({ + devEui, + startDate, + endDate, + timezone, + intervalMinutes, + columns, + ops, + mins, + maxs + }: { + devEui: string; + startDate: Date; + endDate: Date; + timezone: string; + intervalMinutes: number; + columns?: string[]; + ops?: string[]; + mins?: number[]; + maxs?: (number | null)[]; + }): Promise { if (!devEui) { throw new Error('Device EUI not specified'); } @@ -367,7 +378,7 @@ export class DeviceDataService implements IDeviceDataService { * Get alert points for a device from its reports * @param devEui The device EUI */ - public async getAlertPointsForDevice(devEui: string): Promise { + public async getAlertPointsForDevice(devEui: string): Promise { if (!devEui) { throw new Error('Device EUI not specified'); } diff --git a/src/lib/utilities/stats.ts b/src/lib/utilities/stats.ts index c7967393..819b1dd7 100644 --- a/src/lib/utilities/stats.ts +++ b/src/lib/utilities/stats.ts @@ -1,4 +1,5 @@ import { getDarkMode } from '$lib/components/theme/theme.svelte'; +import type { DeviceDataRecord } from '$lib/models/DeviceDataRecord'; /** * Color selection for various statistics keys. @@ -114,11 +115,13 @@ const customNumberFormatOptions: Record = { export const formatNumber = ({ locale = 'en', key, - value + value, + adjustFractionDigits = false }: { locale?: string; key: string; value: number | string | undefined; + adjustFractionDigits?: boolean; }): string => { if (value === undefined) { return 'N/A'; @@ -126,5 +129,34 @@ export const formatNumber = ({ const options = customNumberFormatOptions[key] ?? defaultNumberFormatOptions; + if (adjustFractionDigits) { + options.minimumFractionDigits = options.maximumFractionDigits; + } + return Intl.NumberFormat(locale, options).format(Number(value)); }; + +/** + * Gets all numeric keys from the historical data, excluding specified ignored keys. + * @param historicalData Historical data array. + * @param ignoredDataKeys Array of keys to ignore. + * @returns Array of numeric keys found in the data. + */ +export function getNumericKeys( + historicalData: DeviceDataRecord[], + ignoredDataKeys: string[] = ['id', 'dev_eui', 'created_at'] +): string[] { + if (!historicalData || !historicalData.length) { + return []; + } + + const sample = historicalData.find((row) => row && typeof row === 'object'); + + if (!sample) { + return []; + } + + return Object.keys(sample).filter( + (key) => !ignoredDataKeys.includes(key) && typeof sample[key] === 'number' + ); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ea7db89a..baca65f3 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -93,8 +93,9 @@
{@render children()} @@ -122,5 +123,10 @@ margin-left: 0 !important; padding-top: 119px !important; } + + /* Auth pages should have no padding even on mobile */ + main[data-auth-page] { + padding-top: 0 !important; + } } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 24ce8277..052d4497 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,200 +1,6 @@ - -
-

Toast Notification Demo

- - -
-

Click the buttons below to see different types of toast notifications:

- -
- - - - - - - - - - - -
-
- -
-

How to Use Toast Notifications

-
-

Import the toast store in your Svelte component:

-
{importExample}
- -

Then use any of these methods:

-
{usageExample}
-
-
- -
-

Temperature Range Visualization

-
- - - - - {#if showDataPicker} - { - const itemLength = points.length; - points.push({ - id: points.length + 1, - name: e.name, - operator: e.operator, - value: e.value, - min: e.min, - max: e.max, - color: e.color - }); - showDataPicker = false; - if (itemLength !== points.length) { - success('Point added successfully!'); - } else { - error('Failed to add point.'); - } - }} - /> - {/if} -
-
-
- -
- -
- - diff --git a/src/routes/api/auth/google/+server.ts b/src/routes/api/auth/google/+server.ts new file mode 100644 index 00000000..87bbf414 --- /dev/null +++ b/src/routes/api/auth/google/+server.ts @@ -0,0 +1,46 @@ +import { json, redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { AuthService } from '$lib/services/AuthService'; +import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; + +export const POST: RequestHandler = async ({ request, locals: { supabase } }) => { + try { + // Create a new AuthService instance with the per-request Supabase client + // This ensures authentication state is isolated per user/request + const errorHandler = new ErrorHandlingService(); + const authService = new AuthService(supabase, errorHandler); + + // Attempt to sign in with the per-request auth service + const { data: authData, error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { + queryParams: { + access_type: 'offline', + prompt: 'consent' + } + } + }); + + if (error || !authData || (authData.url && authData.url.length === 0)) { + return json( + { + error: 'Google login failed. Please try again.' + }, + { status: 500 } + ); + } + + // Login successful - cookies are automatically handled by the per-request client + //console.log('Successfully authenticated user:', email); + + return json(authData.url, { status: 200 }); // Redirect to home or a specific page after successful login + } catch (error) { + console.error('Login error:', error); + return json( + { + error: 'An unexpected error occurred' + }, + { status: 500 } + ); + } +}; diff --git a/src/routes/api/auth/logout/+server.ts b/src/routes/api/auth/logout/+server.ts index 2d412bc6..e465ce26 100644 --- a/src/routes/api/auth/logout/+server.ts +++ b/src/routes/api/auth/logout/+server.ts @@ -1,10 +1,7 @@ import { json, redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; - -import type { IAuthService } from '$lib/interfaces/IAuthService'; -import type { ISessionService } from '$lib/interfaces/ISessionService'; import { AuthService } from '$lib/services/AuthService'; -import type { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; +import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; export const POST: RequestHandler = async ({ locals, cookies }) => { try { diff --git a/src/routes/api/devices/[devEui]/data-jwt-pdf/+server.ts b/src/routes/api/devices/[devEui]/data-jwt-pdf/+server.ts deleted file mode 100644 index bafc4d9b..00000000 --- a/src/routes/api/devices/[devEui]/data-jwt-pdf/+server.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { error, json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { DeviceDataService } from '$lib/services/DeviceDataService'; -import { DateTime } from 'luxon'; -import { createClient, type SupabaseClient } from '@supabase/supabase-js'; -import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; -import PDFDocument from 'pdfkit'; -import { createPDFDataTable } from '$lib/pdf/pdfDataTable'; -import fs from 'fs'; -import path from 'path'; -import { createPDFLineChart } from '$lib/pdf/pdfLineChart'; -import { addFooterPageNumber } from '$lib/pdf/pdfFooterPageNumber'; - -/** - * JWT-authenticated PDF generation endpoint for device data reports - * Designed for server-to-server calls (Node-RED, automation tools, etc.) - * - * Usage: - * GET /api/devices/{devEui}/data-jwt-pdf?start=2025-05-01&end=2025-06-06 - * Headers: Authorization: Bearer {jwt-token} - * - * Returns: PDF file as binary response - */ -export const GET: RequestHandler = async ({ params, url, request, locals: { supabase } }) => { - const { devEui } = params; - - try { - // const supabase = await validateAuth(request); - if (!supabase || supabase === null) { - return json({ error: 'Unauthorized access' }, { status: 401 }); - } - let { data: userData, error: userError } = await supabase.auth.getUser(); - if (userError || !userData) { - console.error('Failed to get user from JWT:', userError?.message); - return json( - { error: `Unauthorized access - ${userError?.message}` }, - { status: userError?.status } - ); - } - let user = userData.user; - // Get query parameters for date range - const startDateParam = url.searchParams.get('start'); - const endDateParam = url.searchParams.get('end'); - - if (!startDateParam || !endDateParam) { - return json( - { - error: 'Missing required parameters: Both start and end dates are required', - example: '?start=2025-05-01&end=2025-06-06' - }, - { status: 400 } - ); - } - - let startDate = new Date(startDateParam); - let endDate = new Date(endDateParam); - - // Validate dates - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { - return json( - { - error: 'Invalid date format: Dates must be in ISO format (YYYY-MM-DD)', - example: '?start=2025-05-01&end=2025-06-06' - }, - { status: 400 } - ); - } - - // Convert dates to Japan timezone and include full day - const tokyoStartDate = DateTime.fromJSDate(startDate).setZone('Asia/Tokyo').startOf('day'); - const tokyoEndDate = DateTime.fromJSDate(endDate).setZone('Asia/Tokyo').endOf('day'); - - // Convert back to UTC for database queries - startDate = tokyoStartDate.toUTC().toJSDate(); - endDate = tokyoEndDate.toUTC().toJSDate(); - - // Get device data using JWT-authenticated client (same method as browser version) - const deviceDataService = new DeviceDataService(supabase); - let deviceData: any[] = []; - let reportInfo: any = {}; - - try { - const deviceDataResponse = await deviceDataService.getDeviceDataForReport( - devEui, - startDate, - endDate, - 'Asia/Tokyo', - 30 // Default interval in minutes - ); - if (deviceDataResponse && deviceDataResponse.length > 0) { - deviceData = deviceDataResponse; - - // Get alert points for the device - const alertPoints = await deviceDataService.getAlertPointsForDevice(devEui); - - // Create proper report info structure - reportInfo = { - dev_eui: devEui, - alert_points: alertPoints - }; - } else { - throw new Error( - `No device data found for ${devEui} in the specified date range: ${startDate.toISOString()} to ${endDate.toISOString()}` - ); - } - } catch (reportError) { - //console.log('getDeviceDataForReport failed, continuing with empty data:', reportError instanceof Error ? reportError.message : 'Unknown error'); - deviceData = []; - } - - if (!deviceData || deviceData.length === 0) { - return json( - { - error: `No data found for device ${devEui} in the specified date range`, - device: devEui, - dateRange: { start: startDateParam, end: endDateParam }, - user: user?.email || 'Unknown user', - suggestion: 'Try a different date range or check if the device has been sending data' - }, - { status: 404 } - ); - } - - // Step 3: Sort the array by `created_at` - deviceData.sort((a, b) => { - const dateA = new Date(a.created_at).getTime(); - const dateB = new Date(b.created_at).getTime(); - return dateA - dateB; // Ascending - }); - - // Generate professional PDF using PDFKit (same as browser version) - const doc: PDFDocument = new PDFDocument({ - size: 'A4', - margin: 40, - info: { - Title: `Device ${devEui} Report`, - Author: `CropWatch API`, - Subject: `Data report for device ${devEui} from ${startDateParam} to ${endDateParam}`, - Creator: 'CropWatch API' - }, - bufferPages: true - }); - - // Define possible font paths for NotoSansJP (Japanese font support) - const possibleFontPaths = [ - path.join(process.cwd(), 'static/fonts/NotoSansJP-Regular.ttf'), - path.join(process.cwd(), 'src/lib/fonts/NotoSansJP-Regular.ttf'), - path.join(process.cwd(), 'server/fonts/NotoSansJP-Regular.ttf'), - 'static/fonts/NotoSansJP-Regular.ttf' - ]; - - // Try to load Japanese font - let fontLoaded = false; - for (const fontPath of possibleFontPaths) { - try { - if (fs.existsSync(fontPath)) { - //console.log(`Loading Japanese font from: ${fontPath}`); - doc.registerFont('NotoSansJP', fontPath); - doc.font('NotoSansJP'); - fontLoaded = true; - break; - } - } catch (e) { - //console.log(`Could not load font from: ${fontPath}`); - } - } - - if (!fontLoaded) { - //console.log('Using default font - Japanese characters may not display correctly'); - } - - // Professional header with Japanese styling - doc.fontSize(24).text(`デバイスレポート: ${devEui}`, { - align: 'center' - }); - - doc.moveDown(); - - // Report metadata - doc - .fontSize(12) - .text(`期間: ${startDateParam} ~ ${endDateParam}`, { align: 'left' }) - .text(`生成者: ${user?.email || 'Unknown user'}`, { align: 'left' }) - .text(`生成日時: ${DateTime.now().setZone('Asia/Tokyo').toFormat('yyyy-MM-dd HH:mm:ss')}`, { - align: 'left' - }) - .text(`総レコード数: ${deviceData.length}`, { align: 'left' }); - // Transform data for the professional table format (exactly like browser version) - const dataa: { date: string; values: number[] }[] = []; - - // Process device data exactly like the browser version (temperature only) - deviceData.forEach((data) => { - const date = DateTime.fromISO(data.created_at) - .setZone('Asia/Tokyo') - .toFormat('yyyy-MM-dd HH:mm'); - - // Only include temperature (single column like browser version) - if (typeof data.temperature_c === 'number') { - dataa.push({ - date, - values: [data.temperature_c] // Single temperature value only - }); - } - }); - - createPDFLineChart(doc, dataa, reportInfo.alert_points, { - title: 'センサーデータトレンド', - width: 600, - height: 400, - xAxisLabel: '時間', - yAxisLabel: '測定値' - }); - createPDFDataTable(doc, dataa, reportInfo); - - addFooterPageNumber(doc, devEui, startDateParam, endDateParam); - - // 5) finalize - doc.end(); - - // Get the PDF as a buffer (async operation) - const chunks: Buffer[] = []; - doc.on('data', (chunk: any) => chunks.push(Buffer.from(chunk))); - - return new Promise((resolve, reject) => { - doc.on('end', () => { - const pdfBuffer = Buffer.concat(chunks); - - // Return the PDF with proper headers - resolve( - new Response(pdfBuffer, { - status: 200, - headers: { - 'Content-Type': 'application/pdf', - 'Content-Disposition': `attachment; filename="device-${devEui}-report-${startDateParam}-to-${endDateParam}.pdf"`, - 'Content-Length': pdfBuffer.length.toString(), - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization' - } - }) - ); - }); - - doc.on('error', (err: any) => { - reject( - json( - { - error: 'Failed to generate PDF', - details: err.message, - device: devEui, - user: user?.email || 'Unknown user' - }, - { status: 500 } - ) - ); - }); - }); - } catch (err) { - console.error(`Error generating PDF for device ${devEui}:`, err); - return json( - { - error: 'Failed to generate PDF report', - details: err instanceof Error ? err.message : 'Unknown error', - device: devEui, - user: 'Unknown user' - }, - { status: 500 } - ); - } -}; - -// const validateAuth = async (request: Request): Promise => { -// const authHeader = request.headers.get('authorization') || request.headers.get('Authorization'); -// const jwt = authHeader?.replace(/^Bearer\s+/i, '').trim(); -// if (!jwt) { -// throw error(401, 'Unauthorized access: No JWT token provided'); -// } -// const jwtSupabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { -// global: { -// headers: { Authorization: `Bearer ${jwt}` } -// }, -// auth: { persistSession: false } -// }); -// return jwtSupabase; -// }; diff --git a/src/routes/api/devices/[devEui]/pdf/+server.ts b/src/routes/api/devices/[devEui]/pdf/+server.ts new file mode 100644 index 00000000..ff27f8ed --- /dev/null +++ b/src/routes/api/devices/[devEui]/pdf/+server.ts @@ -0,0 +1,474 @@ +import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; +import { i18n } from '$lib/i18n/index.svelte'; +import type { DeviceDataRecord } from '$lib/models/DeviceDataRecord'; +import type { ReportAlertPoint } from '$lib/models/Report'; +import type { TableCell, TableRow } from '$lib/pdf'; +import { createPDFDataTable } from '$lib/pdf/pdfDataTable'; +import { addFooterPageNumber } from '$lib/pdf/pdfFooterPageNumber'; +import { createPDFLineChart } from '$lib/pdf/pdfLineChart'; +import { checkMatch, getValue } from '$lib/pdf/utils'; +import { DeviceRepository } from '$lib/repositories/DeviceRepository'; +import { LocationRepository } from '$lib/repositories/LocationRepository'; +import { DeviceDataService } from '$lib/services/DeviceDataService'; +import { DeviceService } from '$lib/services/DeviceService'; +import { LocationService } from '$lib/services/LocationService'; +import { formatNumber, getColorNameByKey, getNumericKeys } from '$lib/utilities/stats'; +import { error, json } from '@sveltejs/kit'; +import fs from 'fs'; +import { DateTime } from 'luxon'; +import path from 'path'; +import PDFDocument from 'pdfkit'; +import { _ } from 'svelte-i18n'; +import { get } from 'svelte/store'; +import type { RequestHandler } from './$types'; + +/** + * JWT-authenticated PDF generation endpoint for device data reports + * Designed for server-to-server calls (Node-RED, automation tools, etc.) + * + * Usage: + * GET /api/devices/{devEui}/pdf?start=2025-05-01&end=2025-06-06 + * Headers: Authorization: Bearer {jwt-token} + * + * Returns: PDF file as binary response + */ +export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) => { + const { devEui } = params; + + try { + // const supabase = await validateAuth(request); + if (!supabase || supabase === null) { + return json({ error: 'Unauthorized access' }, { status: 401 }); + } + const { data: userData, error: userError } = await supabase.auth.getUser(); + if (userError || !userData) { + console.error('Failed to get user from JWT:', userError?.message); + return json( + { error: `Unauthorized access - ${userError?.message}` }, + { status: userError?.status } + ); + } + const { user } = userData; + + if (!user) { + throw error(404, 'Device not found'); + } + + const { data: userProfile } = await supabase + .from('profiles') + .select(`full_name, employer`) + .eq('id', user.id) + .single(); + + const { + start: startDateParam, + end: endDateParam, + dataKeys: dataKeysParam = '', + alertPoints: alertPointsParam, + locale: localeParam = 'ja' + } = Object.fromEntries(url.searchParams); + + const selectedKeys = dataKeysParam + .split(',') + .map((key) => key.trim()) + .filter(Boolean); + + const requestedAlertPoints = (() => { + try { + const points = alertPointsParam ? JSON.parse(alertPointsParam) : []; + + if (!Array.isArray(points)) { + throw new Error('alertPoints must be an array'); + } + + return points; + } catch { + return []; + } + })() as ReportAlertPoint[]; + + // Determine if this is a report or a specific data request + const isReport = !requestedAlertPoints.length; + + // Initialize i18n for localized strings + await i18n.initialize({ initialLocale: localeParam }); + const $_ = get(_); + + if (!startDateParam || !endDateParam) { + return json( + { + error: 'Missing required parameters: Both start and end dates are required', + example: '?start=2025-05-01&end=2025-06-06' + }, + { status: 400 } + ); + } + + let startDate = new Date(startDateParam); + let endDate = new Date(endDateParam); + + // Validate dates + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return json( + { + error: 'Invalid date format: Dates must be in ISO format (YYYY-MM-DD)', + example: '?start=2025-05-01&end=2025-06-06' + }, + { status: 400 } + ); + } + + // Convert dates to Japan timezone and include full day + const tokyoStartDate = DateTime.fromJSDate(startDate).setZone('Asia/Tokyo').startOf('day'); + const tokyoEndDate = DateTime.fromJSDate(endDate).setZone('Asia/Tokyo').endOf('day'); + + // Convert back to UTC for database queries + startDate = tokyoStartDate.toUTC().toJSDate(); + endDate = tokyoEndDate.toUTC().toJSDate(); + + // Get device data using JWT-authenticated client (same method as browser version) + const deviceDataService = new DeviceDataService(supabase); + let deviceData: DeviceDataRecord[] = []; + let alertPoints: ReportAlertPoint[] = requestedAlertPoints; + + const errorHandler = new ErrorHandlingService(); + const deviceRepo = new DeviceRepository(supabase, errorHandler); + const deviceService = new DeviceService(deviceRepo); + const device = await deviceService.getDeviceWithTypeByEui(devEui); + + if (!device) { + throw error(404, 'Device not found'); + } + + const locationId = device.location_id; + + if (!locationId) { + throw error(400, 'Invalid location ID'); + } + + const locationRepo = new LocationRepository(supabase, errorHandler); + const locationService = new LocationService(locationRepo, deviceRepo); + const location = await locationService.getLocationById(locationId); + + if (!location) { + throw error(404, 'Location not found'); + } + + try { + const deviceDataResponse = isReport + ? await deviceDataService.getDeviceDataForReport({ + devEui, + startDate, + endDate, + timezone: 'Asia/Tokyo', + columns: requestedAlertPoints.map((point) => point.data_point_key as string), + ops: requestedAlertPoints.map((point) => point.operator as string), + mins: requestedAlertPoints.map((point) => (point.min ?? point.value) as number), + maxs: requestedAlertPoints.map((point) => point.max ?? null), + intervalMinutes: 30 // Default interval in minutes + }) + : await deviceDataService.getDeviceDataByDateRange(devEui, startDate, endDate); + + if (deviceDataResponse && deviceDataResponse.length > 0) { + deviceData = deviceDataResponse; + + // Get alert points for the device if no specific keys are selected + if (!alertPoints.length) { + alertPoints = await deviceDataService.getAlertPointsForDevice(devEui); + } + } else { + throw new Error( + `No device data found for ${devEui} in the specified date range: ${startDate.toISOString()} to ${endDate.toISOString()}` + ); + } + } catch { + //console.log('getDeviceDataForReport failed, continuing with empty data:', reportError instanceof Error ? reportError.message : 'Unknown error'); + deviceData = []; + } + + if (!deviceData || deviceData.length === 0) { + return json( + { + error: `No data found for device ${devEui} in the specified date range`, + device: devEui, + dateRange: { start: startDateParam, end: endDateParam }, + user: user?.email || 'Unknown user', + suggestion: 'Try a different date range or check if the device has been sending data' + }, + { status: 404 } + ); + } + + // Step 3: Sort the array by `created_at` + deviceData.sort((a, b) => { + const dateA = new Date(a.created_at).getTime(); + const dateB = new Date(b.created_at).getTime(); + return dateA - dateB; // Ascending + }); + + // Generate professional PDF using PDFKit (same as browser version) + const doc = new PDFDocument({ + size: 'A4', + margin: 40, + info: { + Title: `Device ${devEui} Report`, + Author: `CropWatch API`, + Subject: `Data report for device ${devEui} from ${startDateParam} to ${endDateParam}`, + Creator: 'CropWatch API' + }, + bufferPages: true + }); + + // Define possible font paths for NotoSansJP (Japanese font support) + const possibleFontPaths = [ + path.join(process.cwd(), 'static/fonts/NotoSansJP-Regular.ttf'), + path.join(process.cwd(), 'src/lib/fonts/NotoSansJP-Regular.ttf'), + path.join(process.cwd(), 'server/fonts/NotoSansJP-Regular.ttf'), + 'static/fonts/NotoSansJP-Regular.ttf' + ]; + + // Try to load Japanese font + let fontLoaded = false; + for (const fontPath of possibleFontPaths) { + try { + if (fs.existsSync(fontPath)) { + //console.log(`Loading Japanese font from: ${fontPath}`); + doc.registerFont('NotoSansJP', fontPath); + doc.font('NotoSansJP'); + fontLoaded = true; + break; + } + } catch { + //console.log(`Could not load font from: ${fontPath}`); + } + } + + if (!fontLoaded) { + //console.log('Using default font - Japanese characters may not display correctly'); + } + + // Professional header with Japanese styling + doc.fontSize(16).text(`CropWatch ${$_('device_report')}`); + doc.fontSize(10).moveDown(); + + // Report metadata + doc + .text( + `${$_('generated_at')}: ${DateTime.now().setZone('Asia/Tokyo').toFormat('yyyy-MM-dd HH:mm:ss')}`, + { width: 280 } + ) + .text(`${$_('generated_by')}: ${userProfile?.full_name || user.email || $_('Unknown')}`, { + width: 280 + }) + .text(`${$_('Company')}: ${userProfile?.employer || $_('Unknown')}`, { width: 280 }) + .text(`${$_('Device Type')}: ${device.cw_device_type?.name || $_('Unknown')}`, { + width: 280 + }) + .text(`${$_('Device Name')}: ${device.name || $_('Unknown')}`, { width: 280 }) + .text(`${$_('EUI')}: ${devEui}`, { width: 280 }) + .text(`${$_('installed_at')}: ${location.name || $_('Unknown')}`, { width: 280 }); + + // Date, signature section + doc.rect(320, 40, 240, 40).stroke(); + doc.fontSize(10).text($_('date'), 325, 45); + doc.rect(320, 85, 80, 100).stroke(); + doc.text($_('created'), 325, 90); + doc.rect(400, 85, 80, 100).stroke(); + doc.text($_('verified'), 405, 90); + doc.rect(480, 85, 80, 100).stroke(); + doc.text($_('approved'), 485, 90); + doc.fontSize(12).text($_('comment'), 320, 200); + + doc + .fontSize(10) + .text(`${$_('date_range')}: ${startDateParam} – ${endDateParam}`, 40, 200) + .text(`${$_('sampling_size')}: ${deviceData.length}`); + + doc.moveDown(4); + + const numericKeys = getNumericKeys(deviceData); + const validKeys = + isReport || !selectedKeys.length + ? numericKeys + : numericKeys.filter((key) => selectedKeys.includes(key)); + + const keyColumns: TableCell[] = validKeys.map((key) => ({ + label: $_(key), + value: '', + width: 40, + color: getColorNameByKey(key) + })); + + const dataHeader: TableRow = { + header: { label: $_('datetime'), value: '', width: 60 }, + cells: [...keyColumns, { label: $_('comment'), width: 60 }] + }; + + const getDataRows = (keys: string[] = validKeys): TableRow[] => + deviceData.map((data) => ({ + header: { + label: DateTime.fromISO(data.created_at) + .setZone('Asia/Tokyo') + .toFormat('yyyy-MM-dd HH:mm'), + value: new Date(data.created_at) + }, + cells: keys.map((key) => { + const value = data[key] as number; + + return { + label: + typeof data[key] === 'number' + ? formatNumber({ key, value, adjustFractionDigits: true }) + : '', + value, + bgColor: + alertPoints.find((point) => point.data_point_key === key && checkMatch(value, point)) + ?.hex_color ?? '#ffffff' + }; + }) + })); + + // Chart + for (const key of validKeys) { + createPDFLineChart({ + doc, + dataHeader: { + header: { label: $_('datetime'), value: '', width: 60 }, + cells: [ + { + label: $_(key), + value: '', + width: 40, + color: getColorNameByKey(key) + } + ] + }, + dataRows: getDataRows([key]), + alertPoints, + config: { + title: $_(key), + width: 600, + height: 300 + } + }); + } + + doc.moveUp(2); + + // Prepare data rows for the table + const dataRows = getDataRows(); + + // Summary table + createPDFDataTable({ + doc, + config: { + caption: $_('summary') + }, + dataHeader: { + header: { label: $_('status'), width: 60 }, + cells: [...keyColumns, { label: `${$_('comment')}:`, width: 60 }] + }, + dataRows: [ + ...alertPoints.map((alertPoint) => { + const { data_point_key, name, hex_color } = alertPoint; + + return { + header: { label: name, value: '' }, + cells: validKeys.map((key, index) => { + if (key !== data_point_key) { + return { label: '-', value: '' }; + } + + const valueList = dataRows.map((row) => row.cells[index].value as number); + const count = valueList.filter((value) => checkMatch(value, alertPoint)).length; + + return { + label: + `${new Intl.NumberFormat(localeParam, { maximumFractionDigits: 2 }).format(count)} ` + + `(${new Intl.NumberFormat(localeParam, { style: 'percent' }).format(count / valueList.length)})`, + value: count, + bgColor: hex_color || '#ffffff' + }; + }) + }; + }), + ...['min', 'max', 'avg', 'stddev'].map((indicator) => ({ + header: { label: $_(indicator), value: '' }, + cells: validKeys.map((key, index) => { + const valueList = dataRows.map((row) => row.cells[index].value as number); + const value = getValue(valueList, indicator); + + return { label: formatNumber({ key, value }), value }; + }) + })) + ] + }); + + doc.addPage(); // Add Page break because the data table should always start on a new page. + + // Full data table + createPDFDataTable({ + doc, + config: { + caption: $_('data_history') + }, + dataHeader, + dataRows + }); + + addFooterPageNumber(doc, `${device.name} | ${devEui} | ${startDateParam} - ${endDateParam}`); + + // 5) finalize + doc.end(); + + // Get the PDF as a buffer (async operation) + const chunks: Buffer[] = []; + doc.on('data', (chunk: any) => chunks.push(Buffer.from(chunk))); + + return new Promise((resolve, reject) => { + doc.on('end', () => { + const pdfBuffer = Buffer.concat(chunks); + + // Return the PDF with proper headers + resolve( + new Response(pdfBuffer, { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="device-${devEui}-report-${startDateParam}-to-${endDateParam}.pdf"`, + 'Content-Length': pdfBuffer.length.toString(), + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + } + }) + ); + }); + + doc.on('error', (err: any) => { + reject( + json( + { + error: 'Failed to generate PDF', + details: err.message, + device: devEui, + user: user?.email || 'Unknown user' + }, + { status: 500 } + ) + ); + }); + }); + } catch (err) { + console.error(`Error generating PDF for device ${devEui}:`, err); + return json( + { + error: 'Failed to generate PDF report', + details: err instanceof Error ? err.message : 'Unknown error', + device: devEui, + user: 'Unknown user' + }, + { status: 500 } + ); + } +}; diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte index eb0947e9..3b913a7e 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte @@ -4,18 +4,19 @@ import DateRangeSelector from '$lib/components/dashboard/DateRangeSelector.svelte'; import DeviceMap from '$lib/components/dashboard/DeviceMap.svelte'; import DataCard from '$lib/components/DataCard/DataCard.svelte'; + import ExportButton from '$lib/components/devices/ExportButton.svelte'; import Spinner from '$lib/components/Spinner.svelte'; import StatsCard from '$lib/components/StatsCard/StatsCard.svelte'; import Button from '$lib/components/UI/buttons/Button.svelte'; + import MaterialIcon from '$lib/components/UI/icons/MaterialIcon.svelte'; import WeatherCalendar from '$lib/components/WeatherCalendar.svelte'; - import CsvDownloadButton from '$lib/csv/CsvDownloadButton.svelte'; import type { DeviceWithType } from '$lib/models/Device'; import type { DeviceDataRecord } from '$lib/models/DeviceDataRecord'; import { formatDateForInput, formatDateForDisplay as utilFormatDateForDisplay } from '$lib/utilities/helpers'; - import { formatNumber } from '$lib/utilities/stats'; + import { formatNumber, getNumericKeys } from '$lib/utilities/stats'; import type { RealtimeChannel } from '@supabase/supabase-js'; import { DateTime } from 'luxon'; import { onMount, untrack } from 'svelte'; @@ -24,7 +25,6 @@ import { getDeviceDetailDerived, setupDeviceDetail } from './device-detail.svelte'; import Header from './Header.svelte'; import { setupRealtimeSubscription } from './realtime.svelte'; - import MaterialIcon from '$lib/components/UI/icons/MaterialIcon.svelte'; // Get device data from server load function let { data }: PageProps = $props(); @@ -59,7 +59,6 @@ loading, // Bound to deviceDetail.loading error, // Bound to deviceDetail.error processHistoricalData, - getNumericKeys, fetchDataForDateRange, // This is deviceDetail.fetchDataForDateRange renderVisualization, // This is deviceDetail.renderVisualization initializeDateRange // This is deviceDetail.initializeDateRange @@ -299,10 +298,9 @@
- - + {#if numericKeys.length} + + {/if} diff --git a/src/routes/auth/login/GoogleAuthLogin.svelte b/src/routes/auth/login/GoogleAuthLogin.svelte index 2ccc3c60..4e498711 100644 --- a/src/routes/auth/login/GoogleAuthLogin.svelte +++ b/src/routes/auth/login/GoogleAuthLogin.svelte @@ -1,8 +1,44 @@ - + + {#if !isFormValid && !isSubmitting} +

+ Please complete all required fields and accept all agreements to register +

+ {/if} + +
+ + diff --git a/static/login-background-images/beach.jpg b/static/login-background-images/beach.jpg new file mode 100644 index 00000000..c7a567be Binary files /dev/null and b/static/login-background-images/beach.jpg differ diff --git a/static/login-background-images/field1.jpg b/static/login-background-images/field1.jpg new file mode 100644 index 00000000..54cd2ffa Binary files /dev/null and b/static/login-background-images/field1.jpg differ diff --git a/static/login-background-images/field2.jpg b/static/login-background-images/field2.jpg new file mode 100644 index 00000000..33303ff0 Binary files /dev/null and b/static/login-background-images/field2.jpg differ diff --git a/static/login-background-images/greenhouse.jpg b/static/login-background-images/greenhouse.jpg new file mode 100644 index 00000000..0ed92e99 Binary files /dev/null and b/static/login-background-images/greenhouse.jpg differ diff --git a/test-device-data-service.ts b/test-device-data-service.ts index da19822c..191c722f 100644 --- a/test-device-data-service.ts +++ b/test-device-data-service.ts @@ -5,20 +5,26 @@ function testMethod() { // Test with all parameters const service = new DeviceDataService({} as any); - const result1 = service.getDeviceDataForReport( - 'test', - new Date(), - new Date(), - 'UTC', - 30, - ['temperature_c'], - ['>'], - [25.0], - [null] - ); + const result1 = service.getDeviceDataForReport({ + devEui: 'test', + startDate: new Date(), + endDate: new Date(), + timezone: 'UTC', + intervalMinutes: 30, + columns: ['temperature_c'], + ops: ['>'], + mins: [25.0], + maxs: [null] + }); // Test with default parameters - const result2 = service.getDeviceDataForReport('test', new Date(), new Date(), 'UTC', 30); + const result2 = service.getDeviceDataForReport({ + devEui: 'test', + startDate: new Date(), + endDate: new Date(), + timezone: 'UTC', + intervalMinutes: 30 + }); console.log('Method signatures compile correctly'); }