Files
GreenHomeUI/src/app/daily-report/page.tsx
alireza 31b58b7151
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Failing after 1s
fix bug and version check
2025-12-18 19:39:05 +03:30

629 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useEffect, useMemo, useState, useCallback, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { api, TelemetryDto, DailyReportDto } from '@/lib/api'
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/persian-date'
import { BarChart3, ChevronRight, ChevronLeft, Calendar as CalendarIcon, Bell } from 'lucide-react'
import Link from 'next/link'
import Loading from '@/components/Loading'
import {
SummaryTab,
ChartsTab,
WeatherTab,
AnalysisTab,
TABS,
TabType,
WeatherData,
ensureDateFormat,
formatPersianDate,
QOM_LAT,
QOM_LON,
detectDataGaps,
DataGap
} from '@/components/daily-report'
function DailyReportContent() {
const router = useRouter()
const searchParams = useSearchParams()
const [telemetry, setTelemetry] = useState<TelemetryDto[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<TabType>('summary')
const [dailyReport, setDailyReport] = useState<DailyReportDto | null>(null)
const [analysisLoading, setAnalysisLoading] = useState(false)
const [analysisError, setAnalysisError] = useState<string | null>(null)
const [weatherData, setWeatherData] = useState<WeatherData | null>(null)
const [weatherLoading, setWeatherLoading] = useState(false)
const [weatherError, setWeatherError] = useState<string | null>(null)
const [expandedDayIndex, setExpandedDayIndex] = useState<number | null>(null)
const [chartStartMinute, setChartStartMinute] = useState(0) // 00:00
const [chartEndMinute, setChartEndMinute] = useState(1439) // 23:59
const deviceId = Number(searchParams.get('deviceId') ?? '1')
const dateParam = searchParams.get('date') ?? formatPersianDate(getCurrentPersianYear(), getCurrentPersianMonth(), getCurrentPersianDay())
const selectedDate = useMemo(() => {
if (!dateParam) return null
try {
const decodedDate = decodeURIComponent(dateParam)
// Ensure date is in yyyy/MM/dd format
return ensureDateFormat(decodedDate)
} catch (error) {
console.error('Error decoding date parameter:', error)
return null
}
}, [dateParam])
// Navigate to previous day
const goToPreviousDay = useCallback(() => {
if (!selectedDate) return
const prevDay = getPreviousPersianDay(selectedDate)
if (prevDay) {
router.push(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(prevDay)}`)
}
}, [selectedDate, deviceId, router])
// Navigate to next day
const goToNextDay = useCallback(() => {
if (!selectedDate) return
const nextDay = getNextPersianDay(selectedDate)
if (nextDay) {
router.push(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(nextDay)}`)
}
}, [selectedDate, deviceId, router])
// Navigate to calendar to select a date
const goToCalendar = useCallback(() => {
router.push(`/calendar?deviceId=${deviceId}`)
}, [deviceId, router])
const loadData = useCallback(async () => {
if (!selectedDate) {
setLoading(false)
return
}
setLoading(true)
try {
const [year, month, day] = selectedDate.split('/').map(Number)
const startDate = persianToGregorian(year, month, day)
startDate.setHours(0, 0, 0, 0)
const endDate = new Date(startDate)
endDate.setHours(23, 59, 59, 999)
const startUtc = startDate.toISOString()
const endUtc = endDate.toISOString()
const result = await api.listTelemetry({ deviceId, startUtc, endUtc, pageSize: 100000 })
setTelemetry(result.items)
} catch (error) {
console.error('Error loading telemetry:', error)
} finally {
setLoading(false)
}
}, [deviceId, selectedDate])
const loadAnalysis = useCallback(async () => {
if (!selectedDate || dailyReport) return
setAnalysisLoading(true)
setAnalysisError(null)
try {
const report = await api.getDailyReport(deviceId, selectedDate)
setDailyReport(report)
} catch (error) {
console.error('Error loading analysis:', error)
setAnalysisError('خطا در دریافت تحلیل. لطفاً دوباره تلاش کنید.')
} finally {
setAnalysisLoading(false)
}
}, [deviceId, selectedDate, dailyReport])
const loadWeather = useCallback(async () => {
if (weatherData) return
setWeatherLoading(true)
setWeatherError(null)
try {
if (!selectedDate) {
setWeatherError('تاریخ انتخاب نشده است')
return
}
// تبدیل تاریخ شمسی به میلادی
const [year, month, day] = selectedDate.split('/').map(Number)
const gregorianDate = persianToGregorian(year, month, day)
// بررسی اینکه تاریخ امروز است یا گذشته
const today = new Date()
today.setHours(0, 0, 0, 0)
gregorianDate.setHours(0, 0, 0, 0)
const isPast = gregorianDate.getTime() < today.getTime()
let weather: WeatherData
if (isPast) {
// استفاده از Historical API برای روزهای گذشته
const dateStr = gregorianDate.toISOString().split('T')[0] // YYYY-MM-DD
const response = await fetch(
`https://archive-api.open-meteo.com/v1/archive?latitude=${QOM_LAT}&longitude=${QOM_LON}&start_date=${dateStr}&end_date=${dateStr}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunshine_duration&timezone=Asia/Tehran`
)
if (!response.ok) {
throw new Error('Failed to fetch historical weather data')
}
const data = await response.json()
// ساختار داده برای روزهای گذشته (بدون current و hourly)
weather = {
current: {
temperature: data.daily.temperature_2m_max?.[0] || 0,
humidity: 0, // Historical API رطوبت ندارد
windSpeed: data.daily.wind_speed_10m_max?.[0] || 0,
weatherCode: data.daily.weather_code?.[0] || 0,
},
hourly: [], // برای گذشته hourly نداریم
daily: [{
date: data.daily.time?.[0] || dateStr,
tempMax: data.daily.temperature_2m_max?.[0] || 0,
tempMin: data.daily.temperature_2m_min?.[0] || 0,
weatherCode: data.daily.weather_code?.[0] || 0,
precipitation: data.daily.precipitation_sum?.[0] || 0,
precipitationProbability: 0,
uvIndexMax: 0,
sunshineDuration: data.daily.sunshine_duration?.[0] || 0,
windSpeedMax: data.daily.wind_speed_10m_max?.[0] || 0,
}]
}
} else {
// استفاده از Forecast API برای امروز و آینده
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${QOM_LAT}&longitude=${QOM_LON}&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&hourly=temperature_2m,relative_humidity_2m,weather_code,precipitation&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,uv_index_max,sunshine_duration,wind_speed_10m_max&timezone=Asia/Tehran&forecast_days=7`
)
if (!response.ok) {
throw new Error('Failed to fetch weather data')
}
const data = await response.json()
// Get only today's hourly data (first 24 hours)
const todayHourly = data.hourly.time.slice(0, 24).map((time: string, i: number) => ({
time,
temperature: data.hourly.temperature_2m[i],
humidity: data.hourly.relative_humidity_2m[i],
weatherCode: data.hourly.weather_code[i],
precipitation: data.hourly.precipitation[i],
}))
weather = {
current: {
temperature: data.current.temperature_2m,
humidity: data.current.relative_humidity_2m,
windSpeed: data.current.wind_speed_10m,
weatherCode: data.current.weather_code,
},
hourly: todayHourly,
daily: data.daily.time.map((date: string, i: number) => ({
date,
tempMax: data.daily.temperature_2m_max[i],
tempMin: data.daily.temperature_2m_min[i],
weatherCode: data.daily.weather_code[i],
precipitation: data.daily.precipitation_sum[i],
precipitationProbability: data.daily.precipitation_probability_max[i],
uvIndexMax: data.daily.uv_index_max[i],
sunshineDuration: data.daily.sunshine_duration[i],
windSpeedMax: data.daily.wind_speed_10m_max[i],
}))
}
}
setWeatherData(weather)
} catch (error) {
console.error('Error loading weather:', error)
setWeatherError('خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید.')
} finally {
setWeatherLoading(false)
}
}, [weatherData, selectedDate])
useEffect(() => {
// Reset states when date or device changes
setDailyReport(null)
setWeatherData(null)
setAnalysisError(null)
setWeatherError(null)
loadData()
}, [loadData, deviceId, selectedDate])
// Load analysis when switching to analysis tab
useEffect(() => {
if (activeTab === 'analysis') {
loadAnalysis()
}
}, [activeTab, loadAnalysis])
// Load weather when switching to weather tab
useEffect(() => {
if (activeTab === 'weather') {
loadWeather()
}
}, [activeTab, loadWeather])
const sortedTelemetry = useMemo(() => {
return [...telemetry].sort((a, b) => {
const aTime = a.serverTimestampUtc || a.timestampUtc
const bTime = b.serverTimestampUtc || b.timestampUtc
return new Date(aTime).getTime() - new Date(bTime).getTime()
})
}, [telemetry])
// Data arrays
const soil = useMemo(() => sortedTelemetry.map(t => Number(t.soilPercent ?? 0)), [sortedTelemetry])
const temp = useMemo(() => sortedTelemetry.map(t => Number(t.temperatureC ?? 0)), [sortedTelemetry])
const hum = useMemo(() => sortedTelemetry.map(t => Number(t.humidityPercent ?? 0)), [sortedTelemetry])
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
// Min/Max calculations (not currently used but kept for potential future use)
// const tempMinMax = useMemo(() => {
// const min = Math.min(...temp)
// const max = Math.max(...temp)
// return {
// min: min < 0 ? Math.floor(min / 10) * 10 : 0,
// max: max > 40 ? Math.floor(max / 10) * 10 : 40
// }
// }, [temp])
// const luxMinMax = useMemo(() => {
// const max = Math.max(...lux)
// return {
// min: 0,
// max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000
// }
// }, [lux])
// Detect data gaps in the full day data
const dataGaps = useMemo(() => {
const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc)
return detectDataGaps(timestamps, 30) // 30 minutes threshold
}, [sortedTelemetry])
// Filtered telemetry for charts based on minute range
const filteredTelemetryForCharts = useMemo(() => {
return sortedTelemetry.filter(t => {
const timestamp = t.serverTimestampUtc || t.timestampUtc
const date = new Date(timestamp)
const minuteOfDay = date.getHours() * 60 + date.getMinutes()
return minuteOfDay >= chartStartMinute && minuteOfDay <= chartEndMinute
})
}, [sortedTelemetry, chartStartMinute, chartEndMinute])
// Detect gaps in filtered data
const filteredDataGaps = useMemo(() => {
const timestamps = filteredTelemetryForCharts.map(t => t.serverTimestampUtc || t.timestampUtc)
return detectDataGaps(timestamps, 30)
}, [filteredTelemetryForCharts])
// Filtered chart labels
const chartLabels = useMemo(() => {
return filteredTelemetryForCharts.map(t => {
const timestamp = t.serverTimestampUtc || t.timestampUtc
const date = new Date(timestamp)
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
const seconds = date.getSeconds().toString().padStart(2, '0')
return `${hours}:${minutes}:${seconds}`
})
}, [filteredTelemetryForCharts])
// Helper function to insert nulls for gaps
const insertGapsInData = (data: number[], timestamps: string[], gaps: DataGap[]): (number | null)[] => {
if (gaps.length === 0 || data.length < 2) return data
const result: (number | null)[] = []
for (let i = 0; i < data.length; i++) {
result.push(data[i])
// Check if there's a gap after this point
if (i < data.length - 1) {
const currentTime = new Date(timestamps[i])
const nextTime = new Date(timestamps[i + 1])
const currentMinute = currentTime.getHours() * 60 + currentTime.getMinutes()
const nextMinute = nextTime.getHours() * 60 + nextTime.getMinutes()
// Find if any gap exists between current and next
const hasGap = gaps.some(gap =>
currentMinute <= gap.startMinute && nextMinute >= gap.endMinute
)
if (hasGap) {
result.push(null) // Insert null to break the line
}
}
}
return result
}
// Filtered data arrays for charts (with gaps as null)
const filteredTimestamps = useMemo(() =>
filteredTelemetryForCharts.map(t => t.serverTimestampUtc || t.timestampUtc),
[filteredTelemetryForCharts]
)
const chartSoil = useMemo(() => {
const data = filteredTelemetryForCharts.map(t => Number(t.soilPercent ?? 0))
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
const chartTemp = useMemo(() => {
const data = filteredTelemetryForCharts.map(t => Number(t.temperatureC ?? 0))
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
const chartHum = useMemo(() => {
const data = filteredTelemetryForCharts.map(t => Number(t.humidityPercent ?? 0))
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
const chartGas = useMemo(() => {
const data = filteredTelemetryForCharts.map(t => Number(t.gasPPM ?? 0))
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
const chartLux = useMemo(() => {
const data = filteredTelemetryForCharts.map(t => Number(t.lux ?? 0))
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
// Min/Max calculations for filtered charts (filter out nulls)
const chartTempMinMax = useMemo(() => {
const validTemps = chartTemp.filter((t): t is number => t !== null)
if (validTemps.length === 0) return { min: 0, max: 40 }
const min = Math.min(...validTemps)
const max = Math.max(...validTemps)
return {
min: min < 0 ? Math.floor(min / 10) * 10 : 0,
max: max > 40 ? Math.floor(max / 10) * 10 : 40
}
}, [chartTemp])
const chartLuxMinMax = useMemo(() => {
const validLux = chartLux.filter((l): l is number => l !== null)
if (validLux.length === 0) return { min: 0, max: 2000 }
const max = Math.max(...validLux)
return {
min: 0,
max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000
}
}, [chartLux])
if (loading) {
return <Loading message="در حال بارگذاری داده‌ها..." />
}
if (!selectedDate) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center">
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
<button
onClick={goToCalendar}
className="inline-flex items-center gap-2 border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
>
<ChevronRight className="w-4 h-4" />
بازگشت به تقویم
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen p-4 md:p-6">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow-md">
<BarChart3 className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
گزارش روزانه {selectedDate}
</h1>
<p className="text-sm text-gray-500 mt-1">
مشاهده خلاصه و نمودارهای روز
</p>
</div>
</div>
<Link
href={`/alert-settings?deviceId=${deviceId}`}
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
>
<Bell className="w-5 h-5" />
<span className="hidden sm:inline">تنظیمات هشدار</span>
</Link>
</div>
</div>
{/* Date Navigation Buttons */}
<div className="flex items-center justify-center gap-3 mb-4">
<button
onClick={goToPreviousDay}
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
>
<ChevronRight className="w-5 h-5" />
روز قبل
</button>
<button
onClick={goToCalendar}
className="flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white rounded-xl transition-all duration-200 font-medium shadow-md hover:shadow-lg"
>
<CalendarIcon className="w-5 h-5" />
انتخاب تاریخ
</button>
<button
onClick={goToNextDay}
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
>
روز بعد
<ChevronLeft className="w-5 h-5" />
</button>
</div>
{/* Tabs */}
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
{/* Segmented Control for Mobile */}
<div className="p-3 md:p-6 md:pb-0">
<div className="bg-gray-100 rounded-xl p-1 flex md:hidden">
{TABS.map(tab => (
<button
key={tab.value}
onClick={() => setActiveTab(tab.value)}
className={`flex-1 px-2 py-2.5 text-xs font-medium rounded-lg transition-all duration-200 ${
activeTab === tab.value
? 'bg-white text-indigo-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Desktop Tabs */}
<div className="hidden md:flex border-b border-gray-200 -mx-6 -mt-6 mb-6">
{TABS.map(tab => (
<button
key={tab.value}
onClick={() => setActiveTab(tab.value)}
className={`flex-1 px-6 py-4 text-sm font-medium transition-all duration-200 whitespace-nowrap ${
activeTab === tab.value
? 'text-indigo-600 border-b-2 border-indigo-600 bg-indigo-50/50'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="p-4 md:p-6 md:pt-0">
{/* Summary Tab */}
{activeTab === 'summary' && (
<SummaryTab
temperature={{
current: temp.at(-1) ?? 0,
min: Math.min(...temp),
max: Math.max(...temp),
data: temp
}}
humidity={{
current: hum.at(-1) ?? 0,
min: Math.min(...hum),
max: Math.max(...hum),
data: hum
}}
soil={{
current: soil.at(-1) ?? 0,
min: Math.min(...soil),
max: Math.max(...soil),
data: soil
}}
gas={{
current: gas.at(-1) ?? 0,
min: Math.min(...gas),
max: Math.max(...gas),
data: gas
}}
lux={{
current: lux.at(-1) ?? 0,
min: Math.min(...lux),
max: Math.max(...lux),
data: lux
}}
/>
)}
{/* Charts Tab */}
{activeTab === 'charts' && (
<ChartsTab
chartStartMinute={chartStartMinute}
chartEndMinute={chartEndMinute}
onStartMinuteChange={setChartStartMinute}
onEndMinuteChange={setChartEndMinute}
labels={chartLabels}
soil={chartSoil}
humidity={chartHum}
temperature={chartTemp}
lux={chartLux}
gas={chartGas}
tempMinMax={chartTempMinMax}
luxMinMax={chartLuxMinMax}
totalRecords={filteredTelemetryForCharts.length}
dataGaps={dataGaps}
/>
)}
{/* Weather Tab */}
{activeTab === 'weather' && (
<WeatherTab
loading={weatherLoading}
error={weatherError}
weatherData={weatherData}
onRetry={() => {
setWeatherData(null)
setWeatherError(null)
loadWeather()
}}
expandedDayIndex={expandedDayIndex}
onDayToggle={setExpandedDayIndex}
selectedDate={selectedDate}
/>
)}
{/* Analysis Tab */}
{activeTab === 'analysis' && (
<AnalysisTab
loading={analysisLoading}
error={analysisError}
dailyReport={dailyReport}
onRetry={() => {
setDailyReport(null)
setAnalysisError(null)
loadAnalysis()
}}
/>
)}
</div>
</div>
</div>
</div>
)
}
export default function DailyReportPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center">
<div className="w-16 h-16 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600">در حال بارگذاری گزارش...</p>
</div>
</div>
}>
<DailyReportContent />
</Suspense>
)
}