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}
+
+
+
+
+
+
+
+
+
+ {$_('select_date_range')}
+ {$_('select_start_end_dates')}
+
+
+
+
+
+ {#each types as type}
+
+ {/each}
+
+
+
+
+
+{: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 @@
-
-
-
-
-
-
-
-
-
-
-
- {$_('select_date_range')}
- {$_('select_start_end_dates')}
-
-
-
-
-
-
-
-
-
-
-
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 @@