optimize
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Failing after 1s

This commit is contained in:
2025-12-20 01:50:14 +03:30
parent 618960bb5c
commit 3644d57206
10 changed files with 299 additions and 208 deletions

View File

@@ -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 = [

View File

@@ -1,3 +1,3 @@
{
"version": "1766173610406"
"version": "1766182760520"
}

View File

@@ -29,6 +29,32 @@ function DailyReportContent() {
const [forecastWeather, setForecastWeather] = useState<WeatherData | null>(null)
const [forecastWeatherLoading, setForecastWeatherLoading] = useState(false)
// Map summary param to chart key
const paramToChartKey = useCallback((param: string): string => {
const mapping: Record<string, string> = {
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: <SummaryTab temperature={temp} humidity={hum} soil={soil} gas={gas} lux={lux} forecastWeather={forecastWeather} forecastWeatherLoading={forecastWeatherLoading} />,
summary: <SummaryTab temperature={temp} humidity={hum} soil={soil} gas={gas} lux={lux} forecastWeather={forecastWeather} forecastWeatherLoading={forecastWeatherLoading} onCardClick={handleCardClick} />,
charts: (
<Suspense fallback={<Loading message="در حال بارگذاری نمودارها..." />}>
<ChartsTab sortedTelemetry={sortedTelemetry} dataGaps={dataGaps} />

View File

@@ -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 (
<div className="bg-white rounded-xl border border-gray-200 shadow-md hover:shadow-lg transition-shadow duration-300 overflow-hidden">
<div id={id} className="bg-white rounded-xl border border-gray-200 shadow-md hover:shadow-lg transition-shadow duration-300 overflow-hidden">
<div className="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200 px-5 py-3">
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
</div>
@@ -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<string, {
type: 'box';
xMin: number;
xMax: number;
backgroundColor: string;
borderColor: string;
borderWidth: number;
borderDash: number[];
label: { display: boolean };
}> = {}
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 (
<div className="persian-number -mx-2">
<Line
data={{
labels,
datasets: series.map(s => ({
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}
/>
</div>
)

View File

@@ -77,7 +77,7 @@ export const ChartsTab = memo(function ChartsTab({
) : (
<div className="grid gap-6 md:grid-cols-2">
{charts.map(chart => (
<Panel key={chart.key} title={chart.title}>
<Panel key={chart.key} title={chart.title} id={`chart-${chart.key}`}>
<LineChart
labels={chartLabels}
series={[

View File

@@ -10,9 +10,10 @@ type SummaryCardProps = {
minValue: number
maxValue: number
data: number[]
onClick?: () => 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 (
<div className={`${config.bgColor} rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden`}>
<div
className={`${config.bgColor} rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden ${onClick ? 'cursor-pointer' : ''}`}
onClick={onClick}
>
{/* Header */}
<div className="px-4 pt-3 pb-1">
<h3 className="text-sm font-bold text-gray-800">{config.title}</h3>

View File

@@ -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')}
/>
<SummaryCard
param="humidity"
@@ -111,13 +113,7 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
minValue={humiditySummary.min}
maxValue={humiditySummary.max}
data={humidity}
/>
<SummaryCard
param="soil"
currentValue={soilSummary.current}
minValue={soilSummary.min}
maxValue={soilSummary.max}
data={soil}
onClick={() => onCardClick?.('humidity')}
/>
<SummaryCard
param="gas"
@@ -125,6 +121,15 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
minValue={gasSummary.min}
maxValue={gasSummary.max}
data={gas}
onClick={() => onCardClick?.('gas')}
/>
<SummaryCard
param="soil"
currentValue={soilSummary.current}
minValue={soilSummary.min}
maxValue={soilSummary.max}
data={soil}
onClick={() => onCardClick?.('soil')}
/>
<SummaryCard
param="lux"
@@ -132,6 +137,7 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
minValue={luxSummary.min}
maxValue={luxSummary.max}
data={lux}
onClick={() => onCardClick?.('lux')}
/>
</div>

View File

@@ -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,
},
]

View File

@@ -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]
)

View File

@@ -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<number, { description: string; icon: React.ComponentType<{ className?: string }> }> = {
@@ -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),