From 7dbfe581bb43f4cd2df1b34831676e02671ef044 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Sat, 11 Oct 2025 22:18:43 +0900 Subject: [PATCH] report charts have dates now! --- .../components/devices/ExportButton.svelte | 7 +- src/lib/pdf/pdfLineChartImage.ts | 203 ++++++++++++++++-- .../devices/[devEui]/device-detail.svelte.ts | 18 +- 3 files changed, 210 insertions(+), 18 deletions(-) diff --git a/src/lib/components/devices/ExportButton.svelte b/src/lib/components/devices/ExportButton.svelte index 18e2c32c..c712b59a 100644 --- a/src/lib/components/devices/ExportButton.svelte +++ b/src/lib/components/devices/ExportButton.svelte @@ -18,6 +18,7 @@ endDateInputString?: string; alertPoints?: ReportAlertPoint[]; dataKeys?: string[]; + timezone?: string; }; let { @@ -29,7 +30,8 @@ startDateInputString = undefined, endDateInputString = undefined, alertPoints = [], - dataKeys = [] + dataKeys = [], + timezone = 'Asia/Tokyo' }: Props = $props(); // Modal open state and date range setup @@ -59,7 +61,8 @@ end: endDate, alertPoints: JSON.stringify(alertPoints), dataKeys: dataKeys.join(','), - locale: $appLocale ?? 'ja' + locale: $appLocale ?? 'ja', + timezone }); let response: Response | null = null; try { diff --git a/src/lib/pdf/pdfLineChartImage.ts b/src/lib/pdf/pdfLineChartImage.ts index 7e335743..3006102d 100644 --- a/src/lib/pdf/pdfLineChartImage.ts +++ b/src/lib/pdf/pdfLineChartImage.ts @@ -14,12 +14,22 @@ import { } from 'chart.js'; import PDFDocument from 'pdfkit'; import type { TableRow } from '.'; +import { DateTime } from 'luxon'; + +interface ProcessedLabel { + withTime: string[]; + dateOnly: string; + dateKey?: string; +} interface ChartConfig { title?: string; width: number; height: number; options?: ChartOptions; + timezone?: string; + maxUniqueDatesWithTime?: number; + maxLabelsWithTime?: number; } // Attempt to register a bundled font (important for server environments like Vercel where system fonts are minimal) @@ -47,7 +57,9 @@ Chart.defaults.devicePixelRatio = 3; Chart.defaults.font.size = 20; Chart.defaults.font.family = Chart.defaults.font.family || 'sans-serif'; -const DEFAULT_CHART_OPTIONS: ChartOptions = { +const FALLBACK_DATASET_COLORS = ['#2563eb', '#dc2626', '#16a34a', '#ca8a04', '#9333ea', '#c2410c']; + +const DEFAULT_CHART_OPTIONS: ChartOptions<'line'> = { elements: { line: { borderWidth: 4, @@ -70,8 +82,8 @@ const createImage = ({ width, height }: { - data: ChartData; - options?: ChartOptions; + data: ChartData<'line', (number | null)[], string | string[]>; + options?: ChartOptions<'line'>; width: number; height: number; }): Buffer => { @@ -111,20 +123,187 @@ export const createPDFLineChartImage = ({ } const { left: marinLeft, right: marginRight } = doc.page.margins; - const { title, width = 400, height = 300, options = DEFAULT_CHART_OPTIONS } = config; + const { + title, + width = 400, + height = 300, + options = DEFAULT_CHART_OPTIONS, + timezone = 'UTC', + maxUniqueDatesWithTime = 8, + maxLabelsWithTime = 24 + } = config; - const data: ChartData = { - labels: dataRows.map((row) => row.header.shortLabel || row.header.label), - datasets: [ + const parseRowDateTime = (row: TableRow): DateTime | null => { + const rawValue = row.header.value; + if (rawValue instanceof Date) { + return DateTime.fromJSDate(rawValue, { zone: 'utc' }).setZone(timezone); + } + if (typeof rawValue === 'string' && rawValue.trim().length) { + let dt = DateTime.fromISO(rawValue, { setZone: true }); + if (!dt.isValid) dt = DateTime.fromSQL(rawValue, { setZone: true }); + if (!dt.isValid) dt = DateTime.fromRFC2822(rawValue, { setZone: true }); + if (dt.isValid) { + return dt.setZone(timezone); + } + } + if (row.header.label) { + const attemptFormats = ['M/d H:mm', 'M/d HH:mm', 'yyyy-MM-dd HH:mm', 'yyyy-MM-dd']; + for (const format of attemptFormats) { + const dt = DateTime.fromFormat(row.header.label, format, { zone: timezone }); + if (dt.isValid) { + return dt; + } + } + } + return null; + }; + + const uniqueDateKeys = new Set(); + + let lastDateTime: DateTime | null = null; + + let processedLabels: ProcessedLabel[] = dataRows.map((row) => { + const dt = parseRowDateTime(row); + if (dt) { + const dateKey = dt.toISODate() ?? dt.toFormat('yyyy-MM-dd'); + if (dateKey) uniqueDateKeys.add(dateKey); + if (!lastDateTime || dt > lastDateTime) { + lastDateTime = dt; + } + const datePart = dt.toFormat('LLL d'); + const timePart = dt.toFormat('HH:mm'); + return { + withTime: [datePart, timePart], + dateOnly: datePart, + dateKey + }; + } + + const label = row.header.label || row.header.shortLabel || ''; + if (label.includes('\n')) { + const [firstLine, secondLine] = label.split('\n'); + if (firstLine) uniqueDateKeys.add(firstLine); + return { + withTime: secondLine ? [firstLine, secondLine] : [firstLine], + dateOnly: firstLine, + dateKey: firstLine || undefined + }; + } + + if (label.includes(' ')) { + const [firstPart, ...rest] = label.split(' '); + const secondPart = rest.join(' ').trim(); + if (firstPart) uniqueDateKeys.add(firstPart); + return { + withTime: secondPart ? [firstPart, secondPart] : [firstPart], + dateOnly: firstPart, + dateKey: firstPart || undefined + }; + } + + if (label) { + uniqueDateKeys.add(label); + } + + return { + withTime: [label], + dateOnly: label, + dateKey: label || undefined + }; + }); + + let appendedNextDayLabel = false; + + if (lastDateTime !== null) { + const nextDayStart = (lastDateTime as DateTime).plus({ days: 1 }).startOf('day'); + const nextDateKey = nextDayStart.toISODate() ?? nextDayStart.toFormat('yyyy-MM-dd'); + if (nextDateKey) uniqueDateKeys.add(nextDateKey); + const datePart = nextDayStart.toFormat('LLL d'); + const timePart = nextDayStart.toFormat('HH:mm'); + processedLabels = [ + ...processedLabels, { - label: dataHeader.header.label, - data: dataRows.map((row) => row.cells[0].value as number), - borderColor: dataHeader.cells[0].color || 'blue' + withTime: [datePart, timePart], + dateOnly: datePart, + dateKey: nextDateKey + } + ]; + appendedNextDayLabel = true; + } + + const includeTime = + uniqueDateKeys.size <= maxUniqueDatesWithTime && processedLabels.length <= maxLabelsWithTime; + + const chartLabels: (string | string[])[] = processedLabels.map((parts) => + includeTime ? parts.withTime : parts.dateOnly + ); + + const firstIndexByDateKey = new Map(); + processedLabels.forEach((entry, index) => { + const key = entry.dateKey ?? entry.dateOnly ?? String(index); + if (!firstIndexByDateKey.has(key)) { + firstIndexByDateKey.set(key, index); + } + }); + + const maxTicksLimit = includeTime + ? Math.min(chartLabels.length, 12) + : Math.min(uniqueDateKeys.size || chartLabels.length, 12); + + const datasets = dataHeader.cells.map((cell, index) => { + const color = cell.color || FALLBACK_DATASET_COLORS[index % FALLBACK_DATASET_COLORS.length]; + const data = dataRows.map((row) => { + const raw = row.cells[index]?.value; + return typeof raw === 'number' && !Number.isNaN(raw) ? raw : null; + }); + if (appendedNextDayLabel) { + data.push(null); + } + return { + label: cell.label, + data, + borderColor: color, + spanGaps: true + }; + }); + + const chartOptions = { + ...DEFAULT_CHART_OPTIONS, + ...options, + scales: { + ...(options?.scales ?? {}), + x: { + type: 'category', + ...(options?.scales?.x ?? {}), + ticks: { + ...(options?.scales?.x?.ticks ?? {}), + autoSkip: false, + maxTicksLimit, + includeBounds: true, + callback(value: string | number, index: number) { + const parsedIndex = + typeof value === 'number' ? value : Number.parseInt(String(value), 10); + const fallbackIndex = Number.isNaN(parsedIndex) ? index : parsedIndex; + const entry = processedLabels[fallbackIndex] ?? processedLabels[index]; + if (!entry) return ''; + const dateKey = entry.dateKey ?? entry.dateOnly ?? String(fallbackIndex); + const firstIndex = firstIndexByDateKey.get(dateKey) ?? fallbackIndex; + if (fallbackIndex !== firstIndex) { + return ''; + } + return includeTime ? entry.withTime : entry.dateOnly; + } + } } - ] + } + } as ChartOptions<'line'>; + + const data: ChartData<'line', (number | null)[], string | string[]> = { + labels: chartLabels, + datasets }; - const buffer = createImage({ data, options, width, height }); + const buffer = createImage({ data, options: chartOptions, width, height }); doc.x = marinLeft; doc.image(buffer, { width, height }); diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/device-detail.svelte.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/device-detail.svelte.ts index ea3d9ffe..1f1287d9 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/device-detail.svelte.ts +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/device-detail.svelte.ts @@ -161,12 +161,22 @@ export function setupDeviceDetail() { error = null; // Clear previous errors try { - // Format Date objects to ISO strings for robust API querying - const startQueryParam = start.toISOString(); // Use parameter - const endQueryParam = end.toISOString(); // Use parameter + // Normalize the dates to calendar-day strings in the user's timezone for the API + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + const startQueryParam = DateTime.fromJSDate(start).setZone(timezone).toISODate(); + const endQueryParam = DateTime.fromJSDate(end).setZone(timezone).toISODate(); + if (!startQueryParam || !endQueryParam) { + throw new Error('Unable to format selected dates for the request.'); + } + + const searchParams = new URLSearchParams({ + start: startQueryParam, + end: endQueryParam, + timezone + }); const response = await fetch( - `/api/devices/${device.dev_eui}/data?start=${startQueryParam}&end=${endQueryParam}` + `/api/devices/${device.dev_eui}/data?${searchParams.toString()}` ); if (!response.ok) {