diff --git a/public/sw.js b/public/sw.js index c27cd3f..8cf56d0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ -const CACHE_NAME = 'greenhome-1766173610406'; -const STATIC_CACHE_NAME = 'greenhome-static-1766173610406'; +const CACHE_NAME = 'greenhome-1766182760520'; +const STATIC_CACHE_NAME = 'greenhome-static-1766182760520'; // Static assets to cache on install const STATIC_FILES_TO_CACHE = [ diff --git a/public/version.json b/public/version.json index 60c20b2..9b633ad 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "1766173610406" + "version": "1766182760520" } \ No newline at end of file diff --git a/src/app/daily-report/page.tsx b/src/app/daily-report/page.tsx index 5a2160a..0435724 100644 --- a/src/app/daily-report/page.tsx +++ b/src/app/daily-report/page.tsx @@ -29,6 +29,32 @@ function DailyReportContent() { const [forecastWeather, setForecastWeather] = useState(null) const [forecastWeatherLoading, setForecastWeatherLoading] = useState(false) + // Map summary param to chart key + const paramToChartKey = useCallback((param: string): string => { + const mapping: Record = { + temperature: 'temp', + humidity: 'hum', + gas: 'gas', + soil: 'soil', + lux: 'lux', + } + return mapping[param] || param + }, []) + + // Handle card click: switch to charts tab and scroll to chart + const handleCardClick = useCallback((param: string) => { + const chartKey = paramToChartKey(param) + setActiveTab('charts') + + // Wait for tab to switch and chart to render, then scroll + setTimeout(() => { + const chartElement = document.getElementById(`chart-${chartKey}`) + if (chartElement) { + chartElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + }, 100) + }, [paramToChartKey]) + const deviceId = Number(searchParams.get('deviceId') ?? '1') const dateParam = searchParams.get('date') ?? formatPersianDate(getCurrentPersianYear(), getCurrentPersianMonth(), getCurrentPersianDay()) @@ -217,7 +243,7 @@ function DailyReportContent() { className="md:mx-0 mx-[-1rem] md:rounded-xl rounded-none" > {{ - summary: , + summary: , charts: ( }> diff --git a/src/components/Charts.tsx b/src/components/Charts.tsx index 19f12af..bc57a7e 100644 --- a/src/components/Charts.tsx +++ b/src/components/Charts.tsx @@ -1,5 +1,5 @@ "use client" -import React from 'react' +import React, { useCallback, useMemo } from 'react' import { Line } from 'react-chartjs-2' import { Chart, @@ -11,15 +11,40 @@ import { Tooltip, Filler } from 'chart.js' -import annotationPlugin from 'chartjs-plugin-annotation' +import { toPersianDigits } from '@/lib/format/persian-digits' -Chart.register(LineElement, CategoryScale, LinearScale, PointElement, Legend, Tooltip, Filler, annotationPlugin) +Chart.register(LineElement, CategoryScale, LinearScale, PointElement, Legend, Tooltip, Filler) -// تابع تبدیل ارقام انگلیسی به فارسی -function toPersianDigits(str: string | number): string { - const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] - return str.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)]) -} +// Constants +const FONT_FAMILY = "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif" +const FONT_SIZE = 10 + +const GAP_ANNOTATION_COLORS = { + backgroundColor: 'rgba(239, 68, 68, 0.1)', + borderColor: 'rgba(239, 68, 68, 0.3)', +} as const + +const RESPONSIVE_CONFIG = { + mobile: { + pointRadius: 0.5, + pointHoverRadius: 3, + animationDuration: 300, + }, + desktop: { + pointRadius: 1, + pointHoverRadius: 4, + animationDuration: 1000, + }, +} as const + +const CHART_LAYOUT_PADDING = { + left: 0, + right: 0, + top: 10, + bottom: 0, +} as const + +const AXIS_BORDER_COLOR = 'rgba(0, 0, 0, 0.1)' type Series = { label: string; data: (number | null)[]; borderColor: string; backgroundColor?: string; fill?: boolean } @@ -37,9 +62,9 @@ type Props = { dataGaps?: DataGapAnnotation[] // Indices where gaps occur } -export function Panel({ title, children }: { title: string; children: React.ReactNode }) { +export function Panel({ title, children, id }: { title: string; children: React.ReactNode; id?: string }) { return ( -
+

{title}

@@ -51,48 +76,16 @@ export function Panel({ title, children }: { title: string; children: React.Reac } export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) { - // Find gap annotations based on null values in data - const gapAnnotations = React.useMemo(() => { - const annotations: Record = {} - let gapCount = 0 - - // Find nulls in the first series data - if (series.length > 0) { - const data = series[0].data - for (let i = 0; i < data.length; i++) { - if (data[i] === null) { - // Find the gap range - const startIdx = Math.max(0, i - 1) - const endIdx = Math.min(data.length - 1, i + 1) - - annotations[`gap-${gapCount++}`] = { - type: 'box' as const, - xMin: startIdx, - xMax: endIdx, - backgroundColor: 'rgba(239, 68, 68, 0.1)', - borderColor: 'rgba(239, 68, 68, 0.3)', - borderWidth: 1, - borderDash: [5, 5], - label: { - display: false - } - } - } - } - } - - return annotations - }, [series]) - + // Check if mobile device + const isMobile = useMemo(() => { + return typeof window !== 'undefined' && window.innerWidth < 768 + }, []) + + // Get responsive config + const responsiveConfig = useMemo(() => { + return isMobile ? RESPONSIVE_CONFIG.mobile : RESPONSIVE_CONFIG.desktop + }, [isMobile]) + // Calculate hour range and determine which hours to show (not currently used but kept for potential future use) // eslint-disable-next-line @typescript-eslint/no-unused-vars const hourConfig = React.useMemo(() => { @@ -130,135 +123,191 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) { }, [labels]) // Create map of label index to hour - const labelHours = React.useMemo(() => { + const labelHours = useMemo(() => { return labels.map(label => { if (!label) return null const [hour] = label.split(':').map(Number) return hour }) }, [labels]) + // X-axis tick callback - optimized with useCallback + const xAxisTickCallback = useCallback((value: unknown, index: number) => { + // Skip if index is out of bounds + if (index < 0 || index >= labelHours.length) return '' + + const hour = labelHours[index] + if (hour === null) return '' + + // Find if this is the first occurrence of this hour + const firstIndexOfHour = labelHours.findIndex(h => h === hour) + + // Only show label if this is the first occurrence of this hour + if (firstIndexOfHour !== index) return '' + + // Show only the hour number + return toPersianDigits(hour.toString()) + }, [labelHours]) + + // Tooltip callbacks + const tooltipCallbacks = useMemo(() => ({ + label: (context: { dataset: { label?: string }; parsed: { y: number } }) => { + const label = context.dataset.label || '' + return `${label}: ${toPersianDigits(context.parsed.y.toFixed(2))}` + }, + title: (context: Array<{ label?: string }>) => { + const label = context[0]?.label || '' + return `ساعت: ${label}` + } + }), []) + + // Font configuration + const fontConfig = useMemo(() => ({ + family: FONT_FAMILY, + size: FONT_SIZE, + }), []) + + // Dataset configuration with segment for gaps + const datasets = useMemo(() => { + return series.map((s, seriesIndex) => { + return { + label: s.label, + data: s.data, + borderColor: s.borderColor, + backgroundColor: s.backgroundColor ?? s.borderColor, + fill: s.fill ?? false, + tension: 0.3, + pointRadius: responsiveConfig.pointRadius, + pointHoverRadius: responsiveConfig.pointHoverRadius, + borderWidth: 1, + spanGaps: true, // Must be true for segment to work on gap connections + // Use segment to style gaps + segment: { + borderDash: (ctx: {p0DataIndex: number, p1DataIndex: number}) => { + const index0 = ctx.p0DataIndex + const index1 = ctx.p1DataIndex + const data = s.data + + // If either point is null, it's a gap + if (data[index0] === null || data[index1] === null) { + return [10, 10] + } + + // Check if there are any null values between these two points + const start = Math.min(index0, index1) + const end = Math.max(index0, index1) + for (let i = start + 1; i < end; i++) { + if (data[i] === null) { + return [10, 10] // There's a gap between these points + } + } + + return [] + }, + borderColor: (ctx: {p0DataIndex: number, p1DataIndex: number}) => { + const index0 = ctx.p0DataIndex + const index1 = ctx.p1DataIndex + const data = s.data + + // If either point is null, use gap color + if (data[index0] === null || data[index1] === null) { + return GAP_ANNOTATION_COLORS.borderColor + } + + // Check if there are any null values between these two points + const start = Math.min(index0, index1) + const end = Math.max(index0, index1) + for (let i = start + 1; i < end; i++) { + if (data[i] === null) { + return GAP_ANNOTATION_COLORS.borderColor // There's a gap between these points + } + } + + return s.borderColor + } + } + } + }) + }, [series, responsiveConfig]) + + // Chart options + const chartOptions = useMemo(() => ({ + responsive: true, + maintainAspectRatio: true, + animation: { + duration: responsiveConfig.animationDuration, + }, + interaction: { + intersect: false, + mode: 'index' as const, + }, + layout: { + padding: CHART_LAYOUT_PADDING, + }, + font: { + family: FONT_FAMILY, + }, + plugins: { + legend: { + display: false + }, + tooltip: { + displayColors: false, + titleFont: fontConfig, + bodyFont: fontConfig, + callbacks: tooltipCallbacks, + } + }, + scales: { + x: { + display: true, + ticks: { + font: fontConfig, + maxRotation: 0, + minRotation: 0, + autoSkip: false, + callback: xAxisTickCallback, + }, + grid: { + display: false + }, + border: { + display: true, + color: AXIS_BORDER_COLOR + } + }, + y: { + display: true, + min: yAxisMin !== undefined ? yAxisMin : undefined, + max: yAxisMax !== undefined ? yAxisMax : undefined, + ticks: { + font: fontConfig, + padding: 8, + callback: (value: unknown) => { + if (typeof value === 'number' || typeof value === 'string') { + return toPersianDigits(value.toString()) + } + return '' + } + }, + grid: { + display: true + }, + border: { + display: true, + color: AXIS_BORDER_COLOR + } + } + } + }), [responsiveConfig, fontConfig, tooltipCallbacks, xAxisTickCallback, yAxisMin, yAxisMax]) return (
({ - label: s.label, - data: s.data, - borderColor: s.borderColor, - backgroundColor: s.backgroundColor ?? s.borderColor, - fill: s.fill ?? false, - tension: 0.3, - pointRadius: typeof window !== 'undefined' && window.innerWidth < 768 ? 2 : 1.5, // Smaller points on mobile - pointHoverRadius: typeof window !== 'undefined' && window.innerWidth < 768 ? 3 : 4, - borderWidth: 2, - spanGaps: false // Don't connect points across null values (gaps) - })) - }} - options={{ - responsive: true, - maintainAspectRatio: true, - animation: { - duration: typeof window !== 'undefined' && window.innerWidth < 768 ? 300 : 1000, // Faster animations on mobile - }, - interaction: { - intersect: false, - mode: 'index' as const, - }, - layout: { - padding: { - left: 0, - right: 0, - top: 10, - bottom: 0 - } - }, - font: { - family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif" - }, - plugins: { - legend: { - display: false // Hide legend - }, - tooltip: { - titleFont: { - family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif" - }, - bodyFont: { - family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif" - }, - callbacks: { - label: function(context) { - return `${context.dataset.label}: ${toPersianDigits(context.parsed.y.toFixed(2))}` - }, - title: function(context) { - return `ساعت: ${context[0].label}` - } - } - }, - annotation: { - annotations: gapAnnotations - } - }, - scales: { - x: { - display: true, - ticks: { - font: { - family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif", - size: 10 - }, - maxRotation: 0, - minRotation: 0, - autoSkip: false, - callback: function(value, index) { - // Get hour for this index - const hour = labelHours[index] - if (hour === null) return '' - - // Find if this is the first occurrence of this hour - const firstIndexOfHour = labelHours.findIndex(h => h === hour) - - // Only show label if this is the first occurrence of this hour - if (firstIndexOfHour !== index) return '' - - // Show only the hour number - return toPersianDigits(hour.toString()) - } - }, - grid: { - display: false // Remove grid lines - }, - border: { - display: true, - color: 'rgba(0, 0, 0, 0.1)' - } - }, - y: { - display: true, - min: yAxisMin !== undefined ? yAxisMin : undefined, - max: yAxisMax !== undefined ? yAxisMax : undefined, - ticks: { - font: { - family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif", - size: 10 - }, - padding: 8, - callback: function(value) { - return toPersianDigits(value.toString()) - } - }, - grid: { - display: false // Remove grid lines - }, - border: { - display: true, - color: 'rgba(0, 0, 0, 0.1)' - } - } - } + datasets, }} + options={chartOptions} />
) diff --git a/src/components/daily-report/ChartsTab.tsx b/src/components/daily-report/ChartsTab.tsx index c4f7ece..b7072f7 100644 --- a/src/components/daily-report/ChartsTab.tsx +++ b/src/components/daily-report/ChartsTab.tsx @@ -77,7 +77,7 @@ export const ChartsTab = memo(function ChartsTab({ ) : (
{charts.map(chart => ( - + void } -export function SummaryCard({ param, currentValue, minValue, maxValue, data }: SummaryCardProps) { +export function SummaryCard({ param, currentValue, minValue, maxValue, data, onClick }: SummaryCardProps) { const config = paramConfig[param] if (!config) return null @@ -35,7 +36,10 @@ export function SummaryCard({ param, currentValue, minValue, maxValue, data }: S } return ( -
+
{/* Header */}

{config.title}

diff --git a/src/components/daily-report/SummaryTab.tsx b/src/components/daily-report/SummaryTab.tsx index eda7fb7..9efe45f 100644 --- a/src/components/daily-report/SummaryTab.tsx +++ b/src/components/daily-report/SummaryTab.tsx @@ -14,9 +14,10 @@ type SummaryTabProps = { lux: number[] forecastWeather?: WeatherData | null forecastWeatherLoading?: boolean + onCardClick?: (param: string) => void } -export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeather, forecastWeatherLoading = false }: SummaryTabProps) { +export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeather, forecastWeatherLoading = false, onCardClick }: SummaryTabProps) { const [isAlertsDialogOpen, setIsAlertsDialogOpen] = useState(false) const alertsCount = useMemo(() => { @@ -104,6 +105,7 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat minValue={temperatureSummary.min} maxValue={temperatureSummary.max} data={temperature} + onClick={() => onCardClick?.('temperature')} /> - onCardClick?.('humidity')} /> onCardClick?.('gas')} + /> + onCardClick?.('soil')} /> onCardClick?.('lux')} />
diff --git a/src/features/daily-report/chart-config.ts b/src/features/daily-report/chart-config.ts index e6fbaf4..9c08aff 100644 --- a/src/features/daily-report/chart-config.ts +++ b/src/features/daily-report/chart-config.ts @@ -3,14 +3,13 @@ import { NormalizedTelemetry } from './types' export const BASE_CHART_CONFIGS: ChartConfig[] = [ { - key: 'soil', - title: 'رطوبت خاک', - seriesLabel: 'رطوبت خاک (%)', - color: '#16a34a', - bgColor: '#dcfce7', - getValue: (t: NormalizedTelemetry) => t.soil, + key: 'temp', + title: 'دما', + seriesLabel: 'دما (°C)', + color: '#ef4444', + bgColor: '#fee2e2', + getValue: (t: NormalizedTelemetry) => t.temp, yAxisMin: 0, - yAxisMax: 100, }, { key: 'hum', @@ -23,13 +22,24 @@ export const BASE_CHART_CONFIGS: ChartConfig[] = [ yAxisMax: 100, }, { - key: 'temp', - title: 'دما', - seriesLabel: 'دما (°C)', - color: '#ef4444', - bgColor: '#fee2e2', - getValue: (t: NormalizedTelemetry) => t.temp, + key: 'gas', + title: 'گاز CO', + seriesLabel: 'CO (ppm)', + color: '#f59e0b', + bgColor: '#fef3c7', + getValue: (t: NormalizedTelemetry) => t.gas, yAxisMin: 0, + yAxisMax: 100, + }, + { + key: 'soil', + title: 'رطوبت خاک', + seriesLabel: 'رطوبت خاک (%)', + color: '#16a34a', + bgColor: '#dcfce7', + getValue: (t: NormalizedTelemetry) => t.soil, + yAxisMin: 0, + yAxisMax: 100, }, { key: 'lux', @@ -40,15 +50,5 @@ export const BASE_CHART_CONFIGS: ChartConfig[] = [ getValue: (t: NormalizedTelemetry) => t.lux, yAxisMin: 0, }, - { - key: 'gas', - title: 'گاز CO', - seriesLabel: 'CO (ppm)', - color: '#f59e0b', - bgColor: '#fef3c7', - getValue: (t: NormalizedTelemetry) => t.gas, - yAxisMin: 0, - yAxisMax: 100, - }, ] diff --git a/src/features/daily-report/hooks/useTelemetryCharts.ts b/src/features/daily-report/hooks/useTelemetryCharts.ts index a4eb717..b922856 100644 --- a/src/features/daily-report/hooks/useTelemetryCharts.ts +++ b/src/features/daily-report/hooks/useTelemetryCharts.ts @@ -19,7 +19,12 @@ export function useTelemetryCharts({ ) const chartLabels = useMemo( - () => filteredTelemetry.map(t => t.label), + () => { + const labels = filteredTelemetry + .map(t => t.label) + + return labels + }, [filteredTelemetry] ) diff --git a/src/features/daily-report/utils.ts b/src/features/daily-report/utils.ts index a61e1bf..2541086 100644 --- a/src/features/daily-report/utils.ts +++ b/src/features/daily-report/utils.ts @@ -1,6 +1,7 @@ import { Cloud, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-react' import { TelemetryDto } from '@/lib/api' import { NormalizedTelemetry } from './types' +import { toPersianDigits } from '@/lib/format'; // Weather code to description and icon mapping export const weatherCodeMap: Record }> = { @@ -213,7 +214,7 @@ export function normalizeTelemetryData(telemetry: TelemetryDto[]): NormalizedTel return { timestamp: ts, minute: h * 60 + m, - label: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`, + label: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`, soil: Number(t.soilPercent ?? 0), temp: Number(t.temperatureC ?? 0), hum: Number(t.humidityPercent ?? 0),