diff --git a/next.config.ts b/next.config.ts index 2e9099e..54636db 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,7 +2,24 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { reactStrictMode: true, - experimental: {}, + experimental: { + optimizePackageImports: ['lucide-react', 'chart.js', 'react-chartjs-2'], + }, + // Enable compression + compress: true, + // Optimize images + images: { + formats: ['image/avif', 'image/webp'], + deviceSizes: [640, 750, 828, 1080, 1200], + imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], + }, + // Performance optimizations + swcMinify: true, + poweredByHeader: false, + // Better mobile experience + compiler: { + removeConsole: process.env.NODE_ENV === 'production', + }, turbopack: { root: __dirname }, } diff --git a/public/sw.js b/public/sw.js index 99cd753..c27cd3f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ -const CACHE_NAME = 'greenhome-1766074058129'; -const STATIC_CACHE_NAME = 'greenhome-static-1766074058129'; +const CACHE_NAME = 'greenhome-1766173610406'; +const STATIC_CACHE_NAME = 'greenhome-static-1766173610406'; // Static assets to cache on install const STATIC_FILES_TO_CACHE = [ diff --git a/public/version.json b/public/version.json index 0a045f7..60c20b2 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "1766074058129" + "version": "1766173610406" } \ No newline at end of file diff --git a/src/app/alert-settings/page.tsx b/src/app/alert-settings/page.tsx index ea7589a..688fa8d 100644 --- a/src/app/alert-settings/page.tsx +++ b/src/app/alert-settings/page.tsx @@ -31,6 +31,8 @@ import { LucideIcon } from 'lucide-react' import Loading from '@/components/Loading' +import { PageHeader, EmptyState, Modal, IconButton, Badge } from '@/components/common' +import { confirmDialog } from '@/components/utils' type SensorType = 0 | 1 | 2 | 3 | 4 type ComparisonType = 0 | 1 | 2 | 3 @@ -172,9 +174,14 @@ function AlertSettingsContent() { } const handleDelete = async (id: number) => { - if (!window.confirm('آیا از حذف این هشدار اطمینان دارید؟')) { - return - } + const confirmed = await confirmDialog({ + message: 'آیا از حذف این هشدار اطمینان دارید؟', + variant: 'danger', + confirmText: 'حذف', + cancelText: 'انصراف' + }) + + if (!confirmed) return try { await api.deleteAlertCondition(id) @@ -338,21 +345,12 @@ function AlertSettingsContent() {
{/* Header */} -
-
-
-
- -
-
-

- تنظیمات هشدارها -

-

- مدیریت شرایط و هشدارهای دستگاه -

-
-
+ افزودن هشدار -
-
+ } + /> {/* Alerts List */}
@@ -372,17 +370,19 @@ function AlertSettingsContent() {
{alerts.length === 0 ? ( -
- -

هیچ هشداری ثبت نشده است

- -
+ + + افزودن اولین هشدار + + } + /> ) : (
{alerts.map((alert) => ( @@ -391,28 +391,18 @@ function AlertSettingsContent() {
{/* Header */}
-
- {NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)?.icon && ( - - {(() => { - const Icon = NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)!.icon - return - })()} - - )} + n.value === alert.notificationType)?.icon} + > {getNotificationLabel(alert.notificationType)} -
-
- {TIME_TYPES.find(t => t.value === alert.timeType)?.icon && ( - - {(() => { - const Icon = TIME_TYPES.find(t => t.value === alert.timeType)!.icon - return - })()} - - )} + + t.value === alert.timeType)?.icon} + > {getTimeTypeLabel(alert.timeType)} -
+ - + />
@@ -479,43 +467,33 @@ function AlertSettingsContent() {
{/* Modal */} - {showModal && ( -
-
- {/* Modal Header */} -
-

- {editingAlert ? 'ویرایش هشدار' : 'افزودن هشدار جدید'} -

- -
+ - {/* Modal Body */} -
- {/* Preview - Sticky at top */} -
-
-
-
- -
-
-
📢 پیش‌نمایش زنده
-
- {generatePreviewText()} -
-
+ + {/* Preview - Sticky at top */} +
+
+
+
+ +
+
+
📢 پیش‌نمایش زنده
+
+ {generatePreviewText()}
+
+
-
- {/* Notification Type */} +
+ {/* Notification Type */}
-
- )} + +
) } diff --git a/src/app/calendar/month/page.tsx b/src/app/calendar/month/page.tsx index a807a31..1e17a36 100644 --- a/src/app/calendar/month/page.tsx +++ b/src/app/calendar/month/page.tsx @@ -1,7 +1,7 @@ "use client" import { useEffect, useMemo, useState } from 'react' import { api } from '@/lib/api' -import { getCurrentPersianYear, getCurrentPersianMonth } from '@/lib/persian-date' +import { getCurrentPersianYear, getCurrentPersianMonth } from '@/lib/date/persian-date' import Loading from '@/components/Loading' function useQueryParam(name: string) { diff --git a/src/app/calendar/page.tsx b/src/app/calendar/page.tsx index a1fe848..2e76318 100644 --- a/src/app/calendar/page.tsx +++ b/src/app/calendar/page.tsx @@ -2,10 +2,13 @@ import { useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/navigation' import { api } from '@/lib/api' -import { getCurrentPersianYear } from '@/lib/persian-date' -import { Calendar as CalendarIcon, ChevronRight, Database, TrendingUp } from 'lucide-react' -import Link from 'next/link' +import { getCurrentPersianYear } from '@/lib/date/persian-date' +import { Calendar as CalendarIcon, Database, TrendingUp } from 'lucide-react' import Loading from '@/components/Loading' +import { PageHeader, BackLink, Card } from '@/components/common' +import { YearSelector } from '@/components/calendar' +import { MonthCard } from '@/components/cards' +import { StatsCard } from '@/components/cards' const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'] @@ -75,54 +78,36 @@ export default function CalendarPage() {
{/* Header */} -
- - - بازگشت به صفحه اصلی - -
-
- -
-

انتخاب سال و ماه

-
-
+ + {/* Main Card */} -
+ {/* Year Selector */} -
- - -
+ {/* Summary Stats */}
-
- - - {totalDays} روز دارای داده - -
-
- - - {totalRecords} رکورد - -
+ +
@@ -133,40 +118,17 @@ export default function CalendarPage() { const isActive = activeMonths.includes(m) const stats = monthDays[m] return ( - + /> ) })}
-
+
) diff --git a/src/app/daily-report/page.tsx b/src/app/daily-report/page.tsx index 0749e6c..5a2160a 100644 --- a/src/app/daily-report/page.tsx +++ b/src/app/daily-report/page.tsx @@ -1,26 +1,24 @@ "use client" -import { useEffect, useMemo, useState, useCallback, Suspense } from 'react' +import { useEffect, useMemo, useState, useCallback, Suspense, lazy } 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 { api, TelemetryDto } from '@/lib/api' +import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/date/persian-date' +import { formatPersianDate, ensureDateFormat } from '@/lib/format/persian-date' +import { TABS, TabType } from '@/features/daily-report' +import { detectDataGaps } from '@/features/daily-report/utils' +import { BarChart3, Bell, CalendarIcon, ChevronRight } 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' +import { SummaryTab } from '@/components/daily-report' +import { fetchForecastWeather, isToday as checkIsToday, WeatherData } from '@/features/weather' +import { Tabs, PageHeader, Button } from '@/components/common' +import { DateNavigation } from '@/components/navigation' +import { usePullToRefresh } from '@/hooks/usePullToRefresh' + +// Lazy load heavy components +const ChartsTab = lazy(() => import('@/components/daily-report/ChartsTab').then(m => ({ default: m.ChartsTab }))) +const WeatherTab = lazy(() => import('@/components/daily-report/WeatherTab').then(m => ({ default: m.WeatherTab }))) +const AnalysisTab = lazy(() => import('@/components/daily-report/AnalysisTab').then(m => ({ default: m.AnalysisTab }))) function DailyReportContent() { const router = useRouter() @@ -28,15 +26,8 @@ function DailyReportContent() { const [telemetry, setTelemetry] = useState([]) const [loading, setLoading] = useState(true) const [activeTab, setActiveTab] = useState('summary') - const [dailyReport, setDailyReport] = useState(null) - const [analysisLoading, setAnalysisLoading] = useState(false) - const [analysisError, setAnalysisError] = useState(null) - const [weatherData, setWeatherData] = useState(null) - const [weatherLoading, setWeatherLoading] = useState(false) - const [weatherError, setWeatherError] = useState(null) - const [expandedDayIndex, setExpandedDayIndex] = useState(null) - const [chartStartMinute, setChartStartMinute] = useState(0) // 00:00 - const [chartEndMinute, setChartEndMinute] = useState(1439) // 23:59 + const [forecastWeather, setForecastWeather] = useState(null) + const [forecastWeatherLoading, setForecastWeatherLoading] = useState(false) const deviceId = Number(searchParams.get('deviceId') ?? '1') const dateParam = searchParams.get('date') ?? formatPersianDate(getCurrentPersianYear(), getCurrentPersianMonth(), getCurrentPersianDay()) @@ -103,156 +94,48 @@ function DailyReportContent() { } }, [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}¤t=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]) + // Pull-to-refresh for mobile + usePullToRefresh(loadData) 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 + // Load forecast weather data when selectedDate is today - lazy load after main data is loaded useEffect(() => { - if (activeTab === 'analysis') { - loadAnalysis() + if (!selectedDate || loading) { + setForecastWeather(null) + setForecastWeatherLoading(false) + return } - }, [activeTab, loadAnalysis]) - // Load weather when switching to weather tab - useEffect(() => { - if (activeTab === 'weather') { - loadWeather() + const isTodayDate = checkIsToday(selectedDate) + + if (isTodayDate) { + // Delay fetch to ensure page is fully loaded first + setForecastWeatherLoading(true) + setForecastWeather(null) + + // Use setTimeout to ensure page is fully rendered before fetching + const timer = setTimeout(() => { + fetchForecastWeather() + .then(setForecastWeather) + .catch((error) => { + console.error('Error loading forecast weather:', error) + setForecastWeather(null) + }) + .finally(() => { + setForecastWeatherLoading(false) + }) + }, 100) // Small delay to ensure page is rendered + + return () => clearTimeout(timer) + } else { + setForecastWeather(null) + setForecastWeatherLoading(false) } - }, [activeTab, loadWeather]) + }, [selectedDate, loading]) const sortedTelemetry = useMemo(() => { return [...telemetry].sort((a, b) => { @@ -269,141 +152,12 @@ function DailyReportContent() { 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 } @@ -414,13 +168,13 @@ function DailyReportContent() {
تاریخ انتخاب نشده است
- +
) @@ -430,21 +184,11 @@ function DailyReportContent() {
{/* Header */} -
-
-
-
- -
-
-

- گزارش روزانه {selectedDate} -

-

- مشاهده خلاصه و نمودارهای روز -

-
-
+ تنظیمات هشدار -
-
+ } + /> {/* Date Navigation Buttons */} -
- - - -
+ {selectedDate && ( + + )} {/* Tabs */} -
- {/* Segmented Control for Mobile */} -
-
- {TABS.map(tab => ( - - ))} -
- - {/* Desktop Tabs */} -
- {TABS.map(tab => ( - - ))} -
-
- -
- {/* Summary Tab */} - {activeTab === 'summary' && ( - - )} - - {/* Charts Tab */} - {activeTab === 'charts' && ( - - )} - - {/* Weather Tab */} - {activeTab === 'weather' && ( - { - setWeatherData(null) - setWeatherError(null) - loadWeather() - }} - expandedDayIndex={expandedDayIndex} - onDayToggle={setExpandedDayIndex} - selectedDate={selectedDate} - /> - )} - - {/* Analysis Tab */} - {activeTab === 'analysis' && ( - { - setDailyReport(null) - setAnalysisError(null) - loadAnalysis() - }} - /> - )} -
-
+ + {{ + summary: , + charts: ( + }> + + + ), + weather: selectedDate ? ( + }> + + + ) : null, + analysis: selectedDate ? ( + }> + + + ) : null, + }} +
) diff --git a/src/app/day-details/page.tsx b/src/app/day-details/page.tsx index 210b1c1..1f36c53 100644 --- a/src/app/day-details/page.tsx +++ b/src/app/day-details/page.tsx @@ -2,10 +2,12 @@ import { useEffect, useState, useMemo, Suspense } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { api } from '@/lib/api' -import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/persian-date' -import { Calendar as CalendarIcon, ChevronRight, Database } from 'lucide-react' -import Link from 'next/link' +import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/date/persian-date' +import { Calendar as CalendarIcon, Database } from 'lucide-react' import Loading from '@/components/Loading' +import { PageHeader, BackLink, Card } from '@/components/common' +import { WeekdayHeaders } from '@/components/calendar' +import { CalendarDayCell, StatsCard } from '@/components/cards' const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'] @@ -72,34 +74,17 @@ function DayDetailsContent() {
{/* Header */} -
- - - بازگشت به تقویم - -
-
- -
-

- {monthNames[month - 1]} {year} -

-
-
+ + {/* Calendar Grid */} -
+ {/* Weekday Headers */} -
- {['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'].map(day => ( -
- {day} -
- ))} -
+ {/* Days Grid */}
@@ -123,41 +108,34 @@ function DayDetailsContent() { const dateStr = `${year}/${monthStr}/${dayStr}` return ( - + /> ) } else { return ( -
-
{day}
-
+ day={day} + hasData={false} + /> ) } })}
-
+ {/* Summary */}
-
- - - {items.length} روز دارای داده از{' '} - {totalDays} روز ماه - -
+
diff --git a/src/app/device-settings/page.tsx b/src/app/device-settings/page.tsx index fa8f6ef..5f03479 100644 --- a/src/app/device-settings/page.tsx +++ b/src/app/device-settings/page.tsx @@ -1,9 +1,11 @@ "use client" import { useEffect, useState, useCallback } from 'react' import { api, DeviceSettingsDto } from '@/lib/api' -import { Settings, ChevronRight, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, CheckCircle2, Bell } from 'lucide-react' +import { Settings, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, Bell } from 'lucide-react' import Link from 'next/link' import Loading from '@/components/Loading' +import { PageHeader, BackLink, ErrorMessage, SuccessMessage, EmptyState, Card } from '@/components/common' +import { SettingsSection, SettingsInputGroup } from '@/components/settings' function useQueryParam(name: string) { if (typeof window === 'undefined') return null as string | null @@ -93,19 +95,13 @@ export default function DeviceSettingsPage() { if (!deviceId) { return ( -
-
- -
شناسه دستگاه مشخص نشده است
- - - بازگشت به انتخاب دستگاه - -
-
+ + } + /> ) } @@ -113,23 +109,12 @@ export default function DeviceSettingsPage() {
{/* Header */} -
- - - بازگشت به انتخاب دستگاه - -
-
-
- -
-

- تنظیمات {deviceName} -

-
+ + تنظیمات هشدار -
-
+ } + /> {/* Messages */} - {error && ( -
- - {error} -
- )} - {success && ( -
- - {success} -
- )} + {error && } + {success && } {!settings ? ( -
- -
- تنظیمات برای این دستگاه وجود ندارد -
- -
+ + ایجاد تنظیمات پیش‌فرض + + } + /> ) : ( -
+
{/* Temperature Settings */} -
-
-
- -
-

- تنظیمات دما -

-
- -
- - handleInputChange('maxTemperature', parseFloat(e.target.value) || 0)} - className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-all bg-gray-50 focus:bg-white" - /> -
- -
- - handleInputChange('minTemperature', parseFloat(e.target.value) || 0)} - className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-all bg-gray-50 focus:bg-white" - /> -
-
+ + handleInputChange('minTemperature', value)} + onMaxChange={(value) => handleInputChange('maxTemperature', value)} + minUnit="°C" + maxUnit="°C" + /> + {/* Gas Settings */} -
-
-
- -
-

- تنظیمات گاز -

-
- -
- - handleInputChange('maxGasPPM', parseInt(e.target.value) || 0)} - className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-600/20 transition-all bg-gray-50 focus:bg-white" - /> -
- -
- - handleInputChange('minGasPPM', parseInt(e.target.value) || 0)} - className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-600/20 transition-all bg-gray-50 focus:bg-white" - /> -
-
+ + handleInputChange('minGasPPM', value)} + onMaxChange={(value) => handleInputChange('maxGasPPM', value)} + minUnit="ppm" + maxUnit="ppm" + minStep={1} + maxStep={1} + /> + {/* Light Settings */} -
-
-
- -
-

- تنظیمات نور -

-
- -
- - handleInputChange('maxLux', parseFloat(e.target.value) || 0)} - className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-yellow-500 focus:outline-none focus:ring-2 focus:ring-yellow-500/20 transition-all bg-gray-50 focus:bg-white" - /> -
- -
- - handleInputChange('minLux', parseFloat(e.target.value) || 0)} - className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-yellow-500 focus:outline-none focus:ring-2 focus:ring-yellow-500/20 transition-all bg-gray-50 focus:bg-white" - /> -
-
+ + handleInputChange('minLux', value)} + onMaxChange={(value) => handleInputChange('maxLux', value)} + minUnit="Lux" + maxUnit="Lux" + /> + {/* Humidity Settings */} -
-
-
- -
-

- تنظیمات رطوبت هوا -

-
- -
- - handleInputChange('maxHumidityPercent', parseFloat(e.target.value) || 0)} - className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all bg-gray-50 focus:bg-white" - /> -
- -
- - handleInputChange('minHumidityPercent', parseFloat(e.target.value) || 0)} - className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all bg-gray-50 focus:bg-white" - /> -
-
+ + handleInputChange('minHumidityPercent', value)} + onMaxChange={(value) => handleInputChange('maxHumidityPercent', value)} + minUnit="%" + maxUnit="%" + /> +
{/* Save Button */} @@ -345,7 +249,7 @@ export default function DeviceSettingsPage() { )}
-
+ )}
diff --git a/src/app/devices/page.tsx b/src/app/devices/page.tsx index 5e3bb66..1c18fdf 100644 --- a/src/app/devices/page.tsx +++ b/src/app/devices/page.tsx @@ -2,10 +2,13 @@ import { useEffect, useState, useCallback } from 'react' import { useRouter } from 'next/navigation' import { api, DeviceDto, PagedResult } from '@/lib/api' -import { Settings, Calendar, LogOut, ArrowRight, Search, ChevronRight, ChevronLeft } from 'lucide-react' -import Link from 'next/link' +import { Settings, LogOut } from 'lucide-react' import Loading from '@/components/Loading' -import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/persian-date' +import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/date/persian-date' +import { PageHeader, ErrorMessage, EmptyState, BackLink } from '@/components/common' +import { SearchInput } from '@/components/forms' +import { DeviceCard } from '@/components/cards' +import { Pagination } from '@/components/navigation' export default function DevicesPage() { const router = useRouter() @@ -75,9 +78,10 @@ export default function DevicesPage() { } }, [user, currentPage, searchTerm, fetchDevices]) - const handleSearch = (e: React.FormEvent) => { - e.preventDefault() - setSearchTerm(searchInput) + const handleSearch = (searchValue?: string) => { + const valueToUse = searchValue ?? searchInput + setSearchTerm(valueToUse) + setSearchInput(valueToUse) setCurrentPage(1) } @@ -96,24 +100,19 @@ export default function DevicesPage() { if (error && (!pagedResult || pagedResult.items.length === 0)) { return ( -
-
-
-
- -
-

خطا

-

{error}

- -
-
-
+ + + خروج + + } + /> ) } @@ -121,47 +120,30 @@ export default function DevicesPage() {
{/* Header */} -
-
-

- انتخاب دستگاه -

- {user && ( -

- {user.name} {user.family} ({user.mobile}) -

- )} -
- -
+ + + خروج + + } + iconGradient="from-green-500 to-green-600" + /> {/* Search */}
-
-
- - setSearchInput(e.target.value)} - placeholder="جستجو در نام دستگاه، نام صاحب، نام خانوادگی صاحب، موقعیت..." - className="w-full pr-12 pl-4 py-3 rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200" - /> -
- -
+ {searchTerm && (
@@ -195,113 +177,36 @@ export default function DevicesPage() { {pagedResult.items.map((device) => { const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}` return ( - -
-
- -
-
-

- {device.deviceName} -

-

- {device.location || 'بدون موقعیت'} -

-
- {device.userName} {device.userFamily} -
-
-
- -
-
-
- + /> ) })}
{/* Pagination */} {showPagination && ( -
- - -
- {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { - let pageNum: number - if (totalPages <= 5) { - pageNum = i + 1 - } else if (currentPage <= 3) { - pageNum = i + 1 - } else if (currentPage >= totalPages - 2) { - pageNum = totalPages - 4 + i - } else { - pageNum = currentPage - 2 + i - } - - return ( - - ) - })} -
- - -
+ )} ) : ( !loading && ( -
-

هیچ دستگاهی یافت نشد

-
+ ) )} {/* Back to Home */}
- - - بازگشت به صفحه اصلی - +
diff --git a/src/app/globals.css b/src/app/globals.css index e188e3e..5172000 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -21,6 +21,13 @@ * { font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +html { + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; } body { @@ -29,6 +36,27 @@ body { color: var(--foreground); font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-weight: 400; + overscroll-behavior-y: contain; /* Prevent pull-to-refresh on Android */ + -webkit-overflow-scrolling: touch; +} + +/* Better touch targets (minimum 44x44px for mobile) */ +button, a, [role="button"] { + min-height: 44px; + min-width: 44px; + touch-action: manipulation; /* Disable double-tap zoom */ + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +/* Hardware acceleration for animations */ +.transition-all, +.transition-colors, +.transition-transform, +.transition-opacity { + will-change: transform, opacity; + transform: translateZ(0); /* Force GPU acceleration */ } .persian-number { @@ -107,4 +135,25 @@ body { border: none; border-top: 1px solid #e5e7eb; margin: 1.5em 0; +} + +/* Loading skeleton animation */ +@keyframes shimmer { + 0% { + background-position: -1000px 0; + } + 100% { + background-position: 1000px 0; + } +} + +.skeleton { + background: linear-gradient( + 90deg, + #f0f0f0 0%, + #e0e0e0 50%, + #f0f0f0 100% + ); + background-size: 2000px 100%; + animation: shimmer 2s infinite; } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 421a4d3..f479197 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -16,8 +16,13 @@ export const metadata: Metadata = { export const viewport = { width: 'device-width', initialScale: 1, - maximumScale: 1, - userScalable: false + maximumScale: 5, // Allow zoom for accessibility + userScalable: true, // Better for accessibility + viewportFit: 'cover', // For notch support + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#16a34a' }, + { media: '(prefers-color-scheme: dark)', color: '#16a34a' }, + ], } export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index b2dcdea..e8971cb 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,44 +1,17 @@ "use client" -import { useState, useRef, useEffect } from 'react' +import { useState } from 'react' import { api } from '@/lib/api' import { Smartphone, ArrowLeft } from 'lucide-react' import { useRouter } from 'next/navigation' import Loading from '@/components/Loading' +import { MobileInput } from '@/components/forms' +import { ErrorMessage } from '@/components/common' export default function LoginPage() { const [mobile, setMobile] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const router = useRouter() - const mobileRef = useRef(null) - - useEffect(() => { - const checkAutoFill = () => { - setTimeout(() => { - if (mobileRef.current) { - const filledMobile = mobileRef.current.value - if (filledMobile && filledMobile !== mobile) { - setMobile(filledMobile) - } - } - }, 100) - } - - checkAutoFill() - window.addEventListener('load', checkAutoFill) - - return () => { - window.removeEventListener('load', checkAutoFill) - } - }, [mobile]) - - const handleInputChange = (e: React.FormEvent) => { - const value = e.currentTarget.value - // Only allow digits - const digitsOnly = value.replace(/\D/g, '') - setMobile(digitsOnly) - setError(null) - } const normalizeMobile = (mobile: string): string => { const digitsOnly = mobile.replace(/\D/g, '') @@ -111,24 +84,17 @@ export default function LoginPage() { - { + setMobile(value) + setError(null) + }} disabled={loading} - maxLength={11} />
- {error && ( -
- {error} -
- )} + {error && }
- {error && ( -
- {error} -
- )} + {error && }
{ e.preventDefault(); handleVerify(); }} className="space-y-5"> -
- {code.map((digit, index) => ( - { inputRefs.current[index] = el }} - type="text" - inputMode="numeric" - maxLength={1} - value={digit} - onChange={(e) => handleCodeChange(index, e.target.value)} - onKeyDown={(e) => handleKeyDown(index, e)} - onPaste={index === 0 ? handlePaste : undefined} - className="w-14 h-14 text-center text-2xl font-bold rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200" - disabled={loading} - /> - ))} -
+ - {error && ( -
- {error} -
- )} + {error && } + + ) +} + +// Card skeleton for loading states +export function CardSkeleton() { + return ( +
+ + + +
+ ) +} + diff --git a/src/components/README.md b/src/components/README.md new file mode 100644 index 0000000..daa27fa --- /dev/null +++ b/src/components/README.md @@ -0,0 +1,100 @@ +# ساختار کامپوننت‌ها + +این پوشه شامل تمام کامپوننت‌های قابل استفاده مجدد اپلیکیشن است که به صورت استاندارد سازماندهی شده‌اند. + +## ساختار پوشه‌بندی + +``` +components/ +├── common/ # کامپوننت‌های عمومی و پایه +├── forms/ # کامپوننت‌های فرم و ورودی +├── navigation/ # کامپوننت‌های ناوبری +├── cards/ # کامپوننت‌های کارت و نمایش داده +├── alerts/ # کامپوننت‌های هشدار +├── settings/ # کامپوننت‌های تنظیمات +├── calendar/ # کامپوننت‌های تقویم +├── utils/ # کامپوننت‌های کمکی +└── daily-report/ # کامپوننت‌های مخصوص گزارش روزانه +``` + +## کامپوننت‌های Common + +کامپوننت‌های پایه که در همه جا استفاده می‌شوند: + +- **PageHeader**: هدر صفحات با icon و title +- **ErrorMessage**: نمایش پیام خطا +- **SuccessMessage**: نمایش پیام موفقیت +- **EmptyState**: حالت خالی +- **Card**: wrapper برای کارت‌ها +- **Badge**: نشان/برچسب +- **IconButton**: دکمه با icon +- **Modal**: مودال/دیالوگ +- **BackLink**: لینک بازگشت +- **Button**: دکمه (موجود) +- **Dialog**: دیالوگ (موجود) +- **Tabs**: تب‌ها (موجود) + +## کامپوننت‌های Forms + +کامپوننت‌های ورودی و فرم: + +- **MobileInput**: ورودی شماره موبایل با normalization +- **CodeInput**: ورودی کد 4 رقمی با auto-focus +- **SearchInput**: ورودی جستجو با icon +- **FormInput**: wrapper برای input با label و error + +## کامپوننت‌های Navigation + +کامپوننت‌های ناوبری: + +- **DateNavigation**: ناوبری تاریخ (قبل/بعد/تقویم) +- **Pagination**: صفحه‌بندی + +## کامپوننت‌های Cards + +کامپوننت‌های نمایش داده: + +- **DeviceCard**: کارت دستگاه +- **MonthCard**: کارت ماه در تقویم +- **CalendarDayCell**: سلول روز در تقویم +- **StatsCard**: کارت آمار + +## کامپوننت‌های Alerts + +کامپوننت‌های هشدار: + +- **WeatherAlertBanner**: بنر هشدار آب و هوا +- **AlertBadge**: نشان هشدار + +## کامپوننت‌های Settings + +کامپوننت‌های تنظیمات: + +- **SettingsInputGroup**: گروه input برای min/max +- **SettingsSection**: بخش تنظیمات با icon + +## کامپوننت‌های Calendar + +کامپوننت‌های تقویم: + +- **YearSelector**: انتخاب سال +- **WeekdayHeaders**: هدر روزهای هفته + +## کامپوننت‌های Utils + +کامپوننت‌های کمکی: + +- **ResendButton**: دکمه ارسال مجدد با countdown +- **ConfirmDialog**: hook و function برای dialog تأیید + +## نحوه استفاده + +```typescript +// Import از index اصلی +import { PageHeader, ErrorMessage, DeviceCard } from '@/components' + +// یا import مستقیم +import { PageHeader } from '@/components/common' +import { MobileInput } from '@/components/forms' +``` + diff --git a/src/components/alerts/AlertBadge.tsx b/src/components/alerts/AlertBadge.tsx new file mode 100644 index 0000000..237a9ae --- /dev/null +++ b/src/components/alerts/AlertBadge.tsx @@ -0,0 +1,17 @@ +import { LucideIcon } from 'lucide-react' +import { Badge } from '@/components/common/Badge' + +type AlertBadgeProps = { + icon: LucideIcon + label: string + variant?: 'default' | 'success' | 'warning' | 'error' | 'info' +} + +export function AlertBadge({ icon: Icon, label, variant = 'default' }: AlertBadgeProps) { + return ( + + {label} + + ) +} + diff --git a/src/components/alerts/WeatherAlertBanner.tsx b/src/components/alerts/WeatherAlertBanner.tsx new file mode 100644 index 0000000..696e37a --- /dev/null +++ b/src/components/alerts/WeatherAlertBanner.tsx @@ -0,0 +1,39 @@ +import { AlertTriangle, ChevronLeft } from 'lucide-react' +import { toPersianDigits } from '@/lib/format/persian-digits' + +type WeatherAlertBannerProps = { + alertsCount: number + onClick: () => void + className?: string +} + +export function WeatherAlertBanner({ + alertsCount, + onClick, + className +}: WeatherAlertBannerProps) { + return ( +
+ +
+ ) +} + diff --git a/src/components/alerts/index.ts b/src/components/alerts/index.ts new file mode 100644 index 0000000..00be97b --- /dev/null +++ b/src/components/alerts/index.ts @@ -0,0 +1,3 @@ +export { WeatherAlertBanner } from './WeatherAlertBanner' +export { AlertBadge } from './AlertBadge' + diff --git a/src/components/calendar/WeekdayHeaders.tsx b/src/components/calendar/WeekdayHeaders.tsx new file mode 100644 index 0000000..d9bf379 --- /dev/null +++ b/src/components/calendar/WeekdayHeaders.tsx @@ -0,0 +1,27 @@ +import { cn } from '@/lib/utils' + +type WeekdayHeadersProps = { + weekdays?: string[] + className?: string +} + +const defaultWeekdays = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'] + +export function WeekdayHeaders({ + weekdays = defaultWeekdays, + className +}: WeekdayHeadersProps) { + return ( +
+ {weekdays.map((day) => ( +
+ {day} +
+ ))} +
+ ) +} + diff --git a/src/components/calendar/YearSelector.tsx b/src/components/calendar/YearSelector.tsx new file mode 100644 index 0000000..aac0b13 --- /dev/null +++ b/src/components/calendar/YearSelector.tsx @@ -0,0 +1,37 @@ +import { Calendar as CalendarIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type YearSelectorProps = { + years: number[] + selectedYear: number + onYearChange: (year: number) => void + className?: string +} + +export function YearSelector({ + years, + selectedYear, + onYearChange, + className +}: YearSelectorProps) { + return ( +
+ + +
+ ) +} + diff --git a/src/components/calendar/index.ts b/src/components/calendar/index.ts new file mode 100644 index 0000000..5447904 --- /dev/null +++ b/src/components/calendar/index.ts @@ -0,0 +1,3 @@ +export { YearSelector } from './YearSelector' +export { WeekdayHeaders } from './WeekdayHeaders' + diff --git a/src/components/cards/CalendarDayCell.tsx b/src/components/cards/CalendarDayCell.tsx new file mode 100644 index 0000000..fb2fc05 --- /dev/null +++ b/src/components/cards/CalendarDayCell.tsx @@ -0,0 +1,48 @@ +import { Database } from 'lucide-react' +import { cn } from '@/lib/utils' + +type CalendarDayCellProps = { + day: number + hasData: boolean + recordCount?: number + onClick?: () => void + className?: string +} + +export function CalendarDayCell({ + day, + hasData, + recordCount = 0, + onClick, + className +}: CalendarDayCellProps) { + if (hasData) { + return ( + + ) + } + + return ( +
+
{day}
+
+ ) +} + diff --git a/src/components/cards/DeviceCard.tsx b/src/components/cards/DeviceCard.tsx new file mode 100644 index 0000000..3d18201 --- /dev/null +++ b/src/components/cards/DeviceCard.tsx @@ -0,0 +1,40 @@ +import Link from 'next/link' +import { Settings, Calendar } from 'lucide-react' +import { DeviceDto } from '@/lib/api' + +type DeviceCardProps = { + device: DeviceDto + href: string + className?: string +} + +export function DeviceCard({ device, href, className }: DeviceCardProps) { + return ( + +
+
+ +
+
+

+ {device.deviceName} +

+

+ {device.location || 'بدون موقعیت'} +

+
+ {device.userName} {device.userFamily} +
+
+
+ +
+
+
+ + ) +} + diff --git a/src/components/cards/MonthCard.tsx b/src/components/cards/MonthCard.tsx new file mode 100644 index 0000000..fa722cc --- /dev/null +++ b/src/components/cards/MonthCard.tsx @@ -0,0 +1,53 @@ +import { Database } from 'lucide-react' +import { cn } from '@/lib/utils' + +type MonthCardProps = { + name: string + isActive: boolean + stats?: { days: number; records: number } + onClick?: () => void + className?: string +} + +export function MonthCard({ + name, + isActive, + stats, + onClick, + className +}: MonthCardProps) { + return ( + + ) +} + diff --git a/src/components/cards/StatsCard.tsx b/src/components/cards/StatsCard.tsx new file mode 100644 index 0000000..4c4a8a6 --- /dev/null +++ b/src/components/cards/StatsCard.tsx @@ -0,0 +1,32 @@ +import { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' +import { toPersianDigits } from '@/lib/format/persian-digits' + +type StatsCardProps = { + icon: LucideIcon + label: string + value: number | string + unit?: string + className?: string + iconColor?: string +} + +export function StatsCard({ + icon: Icon, + label, + value, + unit, + className, + iconColor = 'text-green-600' +}: StatsCardProps) { + return ( +
+ + + {toPersianDigits(value.toString())} + {unit && ` ${unit}`} {label} + +
+ ) +} + diff --git a/src/components/cards/index.ts b/src/components/cards/index.ts new file mode 100644 index 0000000..f13baac --- /dev/null +++ b/src/components/cards/index.ts @@ -0,0 +1,5 @@ +export { DeviceCard } from './DeviceCard' +export { MonthCard } from './MonthCard' +export { CalendarDayCell } from './CalendarDayCell' +export { StatsCard } from './StatsCard' + diff --git a/src/components/common/BackLink.tsx b/src/components/common/BackLink.tsx new file mode 100644 index 0000000..8d587e6 --- /dev/null +++ b/src/components/common/BackLink.tsx @@ -0,0 +1,29 @@ +import Link from 'next/link' +import { ChevronRight } from 'lucide-react' +import { cn } from '@/lib/utils' + +type BackLinkProps = { + href: string + label?: string + className?: string +} + +export function BackLink({ + href, + label = 'بازگشت', + className +}: BackLinkProps) { + return ( + + + {label} + + ) +} + diff --git a/src/components/common/Badge.tsx b/src/components/common/Badge.tsx new file mode 100644 index 0000000..5dc4608 --- /dev/null +++ b/src/components/common/Badge.tsx @@ -0,0 +1,47 @@ +import { ReactNode } from 'react' +import { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info' + +type BadgeProps = { + children: ReactNode + variant?: BadgeVariant + icon?: LucideIcon + className?: string + size?: 'sm' | 'md' +} + +const variantStyles: Record = { + default: 'bg-gray-100 text-gray-800', + success: 'bg-green-100 text-green-800', + warning: 'bg-orange-100 text-orange-800', + error: 'bg-red-100 text-red-800', + info: 'bg-blue-100 text-blue-800', +} + +const sizeStyles = { + sm: 'text-xs px-2 py-1', + md: 'text-sm px-3 py-1.5', +} + +export function Badge({ + children, + variant = 'default', + icon: Icon, + className, + size = 'md' +}: BadgeProps) { + return ( + + {Icon && } + {children} + + ) +} + diff --git a/src/components/common/Button.tsx b/src/components/common/Button.tsx new file mode 100644 index 0000000..6f163e0 --- /dev/null +++ b/src/components/common/Button.tsx @@ -0,0 +1,114 @@ +import { ButtonHTMLAttributes } from 'react' +import { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type ButtonVariant = 'default' | 'primary' | 'secondary' | 'outline' | 'ghost' +type ButtonSize = 'sm' | 'md' | 'lg' + +type ButtonProps = ButtonHTMLAttributes & { + variant?: ButtonVariant + size?: ButtonSize + icon?: LucideIcon + iconPosition?: 'left' | 'right' + responsiveText?: { + mobile: string + desktop: string + } + tooltip?: string +} + +const variantStyles: Record = { + default: 'bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600', + primary: 'bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white shadow-md hover:shadow-lg', + secondary: 'bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800', + outline: 'border border-gray-300 hover:bg-gray-50 text-gray-700', + ghost: 'hover:bg-gray-100 text-gray-700', +} + +const sizeStyles: Record = { + sm: { + padding: 'px-2 py-1.5', + text: 'text-xs', + icon: 'w-3 h-3', + }, + md: { + padding: 'px-2 sm:px-4 py-2.5', + text: 'text-xs sm:text-sm', + icon: 'w-4 h-4 sm:w-5 sm:h-5', + }, + lg: { + padding: 'px-4 sm:px-6 py-3', + text: 'text-sm sm:text-base', + icon: 'w-5 h-5 sm:w-6 sm:h-6', + }, +} + +export function Button({ + variant = 'default', + size = 'md', + icon: Icon, + iconPosition = 'left', + responsiveText, + tooltip, + children, + className, + ...props +}: ButtonProps) { + const sizeStyle = sizeStyles[size] + const variantStyle = variantStyles[variant] + + const buttonClasses = cn( + 'inline-flex items-center justify-center gap-1 sm:gap-2', + 'rounded-xl transition-all duration-200 font-medium', + 'whitespace-nowrap', + 'shadow-sm hover:shadow-md active:shadow-sm', + 'active:scale-[0.98]', // Native-like press effect + 'touch-manipulation', // Better touch response + 'select-none', // Prevent text selection + sizeStyle.padding, + sizeStyle.text, + variantStyle, + className + ) + + const iconClasses = cn(sizeStyle.icon, 'flex-shrink-0') + + const buttonContent = ( + <> + {Icon && iconPosition === 'left' && } + + {responsiveText ? ( + <> + {responsiveText.desktop} + {responsiveText.mobile} + + ) : ( + {children} + )} + + {Icon && iconPosition === 'right' && } + + ) + + if (tooltip) { + return ( + + ) + } + + return ( + + ) +} + diff --git a/src/components/common/Card.tsx b/src/components/common/Card.tsx new file mode 100644 index 0000000..1fe1939 --- /dev/null +++ b/src/components/common/Card.tsx @@ -0,0 +1,35 @@ +import { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +type CardProps = { + children: ReactNode + className?: string + hover?: boolean + padding?: 'sm' | 'md' | 'lg' | 'none' +} + +export function Card({ + children, + className, + hover = false, + padding = 'md' +}: CardProps) { + const paddingClasses = { + sm: 'p-4', + md: 'p-6', + lg: 'p-8', + none: 'p-0' + } + + return ( +
+ {children} +
+ ) +} + diff --git a/src/components/common/Dialog.tsx b/src/components/common/Dialog.tsx new file mode 100644 index 0000000..893d9f2 --- /dev/null +++ b/src/components/common/Dialog.tsx @@ -0,0 +1,72 @@ +"use client" + +import { useEffect } from 'react' +import { X } from 'lucide-react' + +type DialogProps = { + isOpen: boolean + onClose: () => void + title: string + children: React.ReactNode +} + +export function Dialog({ isOpen, onClose, title, children }: DialogProps) { + // Close on Escape key + useEffect(() => { + if (!isOpen) return + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + } + + document.addEventListener('keydown', handleEscape) + return () => document.removeEventListener('keydown', handleEscape) + }, [isOpen, onClose]) + + // Prevent body scroll when dialog is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { + document.body.style.overflow = '' + } + }, [isOpen]) + + if (!isOpen) return null + + return ( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+ {/* Header */} +
+

{title}

+ +
+ + {/* Content */} +
+ {children} +
+
+
+ ) +} + diff --git a/src/components/common/EmptyState.tsx b/src/components/common/EmptyState.tsx new file mode 100644 index 0000000..767ea52 --- /dev/null +++ b/src/components/common/EmptyState.tsx @@ -0,0 +1,42 @@ +import { ReactNode } from 'react' +import { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type EmptyStateProps = { + icon?: LucideIcon + title?: string + message: string + action?: ReactNode + className?: string +} + +export function EmptyState({ + icon: Icon, + title, + message, + action, + className +}: EmptyStateProps) { + return ( +
+ {Icon && ( +
+ +
+ )} + {title && ( +

{title}

+ )} +

{message}

+ {action && ( +
+ {action} +
+ )} +
+ ) +} + diff --git a/src/components/common/ErrorMessage.tsx b/src/components/common/ErrorMessage.tsx new file mode 100644 index 0000000..982e6ee --- /dev/null +++ b/src/components/common/ErrorMessage.tsx @@ -0,0 +1,61 @@ +import { AlertCircle, X } from 'lucide-react' +import { cn } from '@/lib/utils' + +type ErrorMessageProps = { + message: string + onClose?: () => void + className?: string + fullPage?: boolean + action?: React.ReactNode +} + +export function ErrorMessage({ + message, + onClose, + className, + fullPage = false, + action +}: ErrorMessageProps) { + if (fullPage) { + return ( +
+
+
+
+ +
+

خطا

+

{message}

+ {action} +
+
+
+ ) + } + + return ( +
+
+ + {message} + {onClose && ( + + )} +
+ {action && ( +
+ {action} +
+ )} +
+ ) +} + diff --git a/src/components/common/IconButton.tsx b/src/components/common/IconButton.tsx new file mode 100644 index 0000000..dd11b23 --- /dev/null +++ b/src/components/common/IconButton.tsx @@ -0,0 +1,51 @@ +import { ButtonHTMLAttributes } from 'react' +import { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type IconButtonProps = ButtonHTMLAttributes & { + icon: LucideIcon + variant?: 'default' | 'primary' | 'danger' | 'ghost' + size?: 'sm' | 'md' | 'lg' +} + +const variantStyles = { + default: 'text-gray-600 hover:bg-gray-100', + primary: 'text-blue-600 hover:bg-blue-50', + danger: 'text-red-600 hover:bg-red-50', + ghost: 'text-gray-400 hover:text-gray-600 hover:bg-gray-100', +} + +const sizeStyles = { + sm: 'p-1.5', + md: 'p-2', + lg: 'p-2.5', +} + +const iconSizes = { + sm: 'w-3 h-3', + md: 'w-4 h-4', + lg: 'w-5 h-5', +} + +export function IconButton({ + icon: Icon, + variant = 'default', + size = 'md', + className, + ...props +}: IconButtonProps) { + return ( + + ) +} + diff --git a/src/components/common/Modal.tsx b/src/components/common/Modal.tsx new file mode 100644 index 0000000..d6616ec --- /dev/null +++ b/src/components/common/Modal.tsx @@ -0,0 +1,78 @@ +import { ReactNode, useEffect } from 'react' +import { X } from 'lucide-react' +import { cn } from '@/lib/utils' + +type ModalProps = { + isOpen: boolean + onClose: () => void + title?: string + children: ReactNode + size?: 'sm' | 'md' | 'lg' | 'xl' | 'full' + className?: string + showCloseButton?: boolean +} + +const sizeStyles = { + sm: 'max-w-md', + md: 'max-w-2xl', + lg: 'max-w-4xl', + xl: 'max-w-6xl', + full: 'max-w-full', +} + +export function Modal({ + isOpen, + onClose, + title, + children, + size = 'md', + className, + showCloseButton = true +}: ModalProps) { + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { + document.body.style.overflow = '' + } + }, [isOpen]) + + if (!isOpen) return null + + return ( +
+
e.stopPropagation()} + > + {title && ( +
+

{title}

+ {showCloseButton && ( + + )} +
+ )} +
+ {children} +
+
+
+ ) +} + diff --git a/src/components/common/PageHeader.tsx b/src/components/common/PageHeader.tsx new file mode 100644 index 0000000..b418560 --- /dev/null +++ b/src/components/common/PageHeader.tsx @@ -0,0 +1,52 @@ +import { ReactNode } from 'react' +import { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type PageHeaderProps = { + icon: LucideIcon + title: string + subtitle?: string + action?: ReactNode + iconGradient?: string + className?: string +} + +export function PageHeader({ + icon: Icon, + title, + subtitle, + action, + iconGradient = 'from-indigo-500 to-purple-600', + className +}: PageHeaderProps) { + return ( +
+
+
+
+ +
+
+

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+
+ {action && ( +
+ {action} +
+ )} +
+
+ ) +} + diff --git a/src/components/common/SegmentTab.tsx b/src/components/common/SegmentTab.tsx new file mode 100644 index 0000000..ca7d399 --- /dev/null +++ b/src/components/common/SegmentTab.tsx @@ -0,0 +1,26 @@ +type SegmentTabProps = { + tabs: { value: T; label: string }[] + activeTab: T + className?: string + setActiveTab: React.Dispatch> +} + +export function SegmentTab({ tabs, activeTab, className, setActiveTab }: SegmentTabProps) { + return ( +
+ {tabs.map(tab => ( + + ))} +
+ ) +} \ No newline at end of file diff --git a/src/components/common/SuccessMessage.tsx b/src/components/common/SuccessMessage.tsx new file mode 100644 index 0000000..c16452b --- /dev/null +++ b/src/components/common/SuccessMessage.tsx @@ -0,0 +1,33 @@ +import { CheckCircle2, X } from 'lucide-react' +import { cn } from '@/lib/utils' + +type SuccessMessageProps = { + message: string + onClose?: () => void + className?: string +} + +export function SuccessMessage({ + message, + onClose, + className +}: SuccessMessageProps) { + return ( +
+ + {message} + {onClose && ( + + )} +
+ ) +} + diff --git a/src/components/common/Tabs.tsx b/src/components/common/Tabs.tsx new file mode 100644 index 0000000..48bc72d --- /dev/null +++ b/src/components/common/Tabs.tsx @@ -0,0 +1,62 @@ +import { ReactNode, Dispatch, SetStateAction } from 'react' +import { SegmentTab } from './SegmentTab' + +type TabConfig = { + value: T + label: string +} + +type TabsProps = { + tabs: TabConfig[] + activeTab: T + setActiveTab: Dispatch> + children: Record + className?: string +} + +export function Tabs({ + tabs, + activeTab, + setActiveTab, + children, + className +}: TabsProps) { + return ( +
+ {/* Segmented Control for Mobile */} +
+ + + {/* Desktop Tabs */} +
+ {tabs.map(tab => ( + + ))} +
+
+ + {/* Tab Content */} + {children[activeTab] !== null && children[activeTab] !== undefined && ( +
+ {children[activeTab]} +
+ )} +
+ ) +} + diff --git a/src/components/common/index.ts b/src/components/common/index.ts new file mode 100644 index 0000000..11e5575 --- /dev/null +++ b/src/components/common/index.ts @@ -0,0 +1,14 @@ +export { Button } from './Button' +export { Dialog } from './Dialog' +export { Tabs } from './Tabs' +export { SegmentTab } from './SegmentTab' +export { PageHeader } from './PageHeader' +export { ErrorMessage } from './ErrorMessage' +export { SuccessMessage } from './SuccessMessage' +export { EmptyState } from './EmptyState' +export { Card } from './Card' +export { Badge } from './Badge' +export { IconButton } from './IconButton' +export { Modal } from './Modal' +export { BackLink } from './BackLink' + diff --git a/src/components/daily-report/AnalysisTab.tsx b/src/components/daily-report/AnalysisTab.tsx index 518c4aa..0673687 100644 --- a/src/components/daily-report/AnalysisTab.tsx +++ b/src/components/daily-report/AnalysisTab.tsx @@ -1,48 +1,82 @@ -import { Loader2, AlertCircle, RefreshCw } from 'lucide-react' +import { useState, useEffect, useCallback } from 'react' +import { RefreshCw } from 'lucide-react' import ReactMarkdown from 'react-markdown' -import { DailyReportDto } from '@/lib/api' -import { toPersianDigits } from './utils' +import { DailyReportDto, api } from '@/lib/api' +import { toPersianDigits } from '@/lib/format/persian-digits' +import Loading from '@/components/Loading' +import { ErrorMessage, EmptyState } from '@/components/common' +import { Button } from '@/components/common/Button' +import { FileText } from 'lucide-react' type AnalysisTabProps = { - loading: boolean - error: string | null - dailyReport: DailyReportDto | null - onRetry: () => void + deviceId: number + selectedDate: string } -export function AnalysisTab({ loading, error, dailyReport, onRetry }: AnalysisTabProps) { +export function AnalysisTab({ deviceId, selectedDate }: AnalysisTabProps) { + const [dailyReport, setDailyReport] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const loadAnalysis = useCallback(async () => { + // اگر قبلاً لود شده، دوباره لود نکن + if (dailyReport) return + + setLoading(true) + setError(null) + + try { + const report = await api.getDailyReport(deviceId, selectedDate) + setDailyReport(report) + } catch (error) { + console.error('Error loading analysis:', error) + setError('خطا در دریافت تحلیل. لطفاً دوباره تلاش کنید.') + } finally { + setLoading(false) + } + }, [deviceId, selectedDate, dailyReport]) + + // Reset analysis data when selectedDate changes + useEffect(() => { + setDailyReport(null) + setError(null) + }, [selectedDate]) + + // Load analysis when component mounts (user clicked on tab) + useEffect(() => { + loadAnalysis() + }, [loadAnalysis]) if (loading) { - return ( -
- -

در حال دریافت تحلیل...

-
- ) + return } if (error) { return ( -
-
- -
-

{error}

- -
+ { + setDailyReport(null) + loadAnalysis() + }} + variant="primary" + icon={RefreshCw} + > + تلاش مجدد + + } + /> ) } if (!dailyReport) { return ( -
-

تحلیلی برای نمایش وجود ندارد

-
+ ) } diff --git a/src/components/daily-report/ChartsTab.tsx b/src/components/daily-report/ChartsTab.tsx index c5d4b19..c4f7ece 100644 --- a/src/components/daily-report/ChartsTab.tsx +++ b/src/components/daily-report/ChartsTab.tsx @@ -1,104 +1,106 @@ +import { useState, useMemo, memo, useCallback } from 'react' import { BarChart3 } from 'lucide-react' import { LineChart, Panel } from '@/components/Charts' import { TimeRangeSelector } from './TimeRangeSelector' -import { DataGap } from './utils' +import { DataGap, detectDataGaps, normalizeTelemetryData } from '@/features/daily-report/utils' +import { TelemetryDto } from '@/lib/api' +import { EmptyState } from '@/components/common' +import { useTelemetryCharts } from '@/features/daily-report/hooks/useTelemetryCharts' type ChartsTabProps = { - chartStartMinute: number - chartEndMinute: number - onStartMinuteChange: (minute: number) => void - onEndMinuteChange: (minute: number) => void - labels: string[] - soil: (number | null)[] - humidity: (number | null)[] - temperature: (number | null)[] - lux: (number | null)[] - gas: (number | null)[] - tempMinMax: { min: number; max: number } - luxMinMax: { min: number; max: number } - totalRecords: number + sortedTelemetry: TelemetryDto[] dataGaps?: DataGap[] } -export function ChartsTab({ - chartStartMinute, - chartEndMinute, - onStartMinuteChange, - onEndMinuteChange, - labels, - soil, - humidity, - temperature, - lux, - gas, - tempMinMax, - luxMinMax, - totalRecords, - dataGaps = [] +export const ChartsTab = memo(function ChartsTab({ + sortedTelemetry, + dataGaps = [], }: ChartsTabProps) { + const [chartStartMinute, setChartStartMinute] = useState(0) + const [chartEndMinute, setChartEndMinute] = useState(1439) + + const handleStartMinuteChange = useCallback((minute: number) => { + setChartStartMinute(minute) + }, []) + + const handleEndMinuteChange = useCallback((minute: number) => { + setChartEndMinute(minute) + }, []) + + // Normalize telemetry data + const normalizedTelemetry = useMemo( + () => normalizeTelemetryData(sortedTelemetry), + [sortedTelemetry] + ) + + // Filter by time range + const filteredTelemetry = useMemo(() => { + return normalizedTelemetry.filter( + t => t.minute >= chartStartMinute && t.minute <= chartEndMinute + ) + }, [normalizedTelemetry, chartStartMinute, chartEndMinute]) + + // Detect data gaps in filtered data + const timestamps = useMemo( + () => filteredTelemetry.map(t => t.timestamp), + [filteredTelemetry] + ) + + const filteredDataGaps = useMemo( + () => detectDataGaps(timestamps, 30), + [timestamps] + ) + + // Build charts using custom hook + const { charts, chartLabels } = useTelemetryCharts({ + filteredTelemetry, + filteredDataGaps, + }) + return (
- {/* Time Range Selector */} - {/* Charts Grid */} - {totalRecords === 0 ? ( -
- -

داده‌ای برای این بازه زمانی موجود نیست

-
+ {filteredTelemetry.length === 0 ? ( + ) : (
- - - - - - - - - - - - - - - + {charts.map(chart => ( + + + + ))}
)}
) -} - +}, (prevProps, nextProps) => { + // Custom comparison for better performance + return prevProps.sortedTelemetry.length === nextProps.sortedTelemetry.length && + (prevProps.dataGaps?.length ?? 0) === (nextProps.dataGaps?.length ?? 0) && + prevProps.sortedTelemetry[0]?.id === nextProps.sortedTelemetry[0]?.id +}) diff --git a/src/components/daily-report/GreenhouseForecastAlerts.tsx b/src/components/daily-report/GreenhouseForecastAlerts.tsx new file mode 100644 index 0000000..8eed8d9 --- /dev/null +++ b/src/components/daily-report/GreenhouseForecastAlerts.tsx @@ -0,0 +1,226 @@ +"use client" + +import { useMemo } from 'react' +import { Thermometer, Sun, Droplets, Wind, AlertTriangle } from 'lucide-react' +import { WeatherData, GreenhouseAlert } from '@/features/weather' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { getPersianDayName } from '@/features/daily-report/utils' + +type GreenhouseForecastAlert = GreenhouseAlert & { + daysAhead: number // تعداد روزهای آینده + date: string // تاریخ روز پیش‌بینی +} + +type GreenhouseForecastAlertsProps = { + weatherData: WeatherData +} + +/** + * محاسبه تعداد هشدارهای پیش‌بینی آب و هوا + */ +export function getForecastAlertsCount(weatherData: WeatherData | null): number { + if (!weatherData) return 0 + + let count = 0 + + // بررسی روزهای آینده (از روز دوم به بعد، چون روز اول امروز است) + for (let i = 1; i < weatherData.daily.length; i++) { + const day = weatherData.daily[i] + + if (day.tempMin < 5) count++ // یخ‌زدگی + if (day.tempMax > 35) count++ // گرمای شدید + if (day.uvIndexMax > 8) count++ // UV بالا + if (day.windSpeedMax > 40) count++ // باد شدید + if (day.precipitation > 10) count++ // بارش قابل توجه + if (day.tempMin >= 5 && day.tempMin < 10) count++ // دمای پایین + if (day.tempMax >= 30 && day.tempMax <= 35) count++ // دمای بالا + } + + return count +} + +/** + * کامپوننت هشدارهای پیش‌بینی آب و هوای آینده برای گلخانه + * این کامپوننت فقط برای روزهای آینده (نه امروز) هشدار می‌دهد + */ +export function GreenhouseForecastAlerts({ weatherData }: GreenhouseForecastAlertsProps) { + const alertsByDay = useMemo(() => { + const alertsMap = new Map() + + // بررسی روزهای آینده (از روز دوم به بعد، چون روز اول امروز است) + for (let i = 1; i < weatherData.daily.length; i++) { + const day = weatherData.daily[i] + const daysAhead = i // تعداد روزهای آینده + const dayAlerts: GreenhouseForecastAlert[] = [] + + // هشدار یخ‌زدگی (دمای حداقل کمتر از 5 درجه) + if (day.tempMin < 5) { + dayAlerts.push({ + type: 'danger', + title: '⚠️ هشدار یخ‌زدگی', + description: `دمای حداقل ${toPersianDigits(Math.round(day.tempMin))}°C پیش‌بینی شده. سیستم گرمایش را آماده کنید و پوشش محافظ روی گیاهان حساس قرار دهید.`, + icon: Thermometer, + daysAhead, + date: day.date + }) + } + + // هشدار گرمای شدید (دمای حداکثر بیشتر از 35 درجه) + if (day.tempMax > 35) { + dayAlerts.push({ + type: 'danger', + title: '🌡️ هشدار گرمای شدید', + description: `دمای حداکثر ${toPersianDigits(Math.round(day.tempMax))}°C پیش‌بینی شده. سایه‌بان‌ها را فعال کنید، تهویه را افزایش دهید و آبیاری را در ساعات خنک انجام دهید.`, + icon: Sun, + daysAhead, + date: day.date + }) + } + + // هشدار شاخص UV بالا (بیشتر از 8) + if (day.uvIndexMax > 8) { + dayAlerts.push({ + type: 'warning', + title: '☀️ شاخص UV بالا', + description: `شاخص UV ${toPersianDigits(Math.round(day.uvIndexMax))} است. برای گیاهان حساس به نور از سایه‌بان استفاده کنید.`, + icon: Sun, + daysAhead, + date: day.date + }) + } + + // هشدار باد شدید (بیشتر از 40 کیلومتر بر ساعت) + if (day.windSpeedMax > 40) { + dayAlerts.push({ + type: 'warning', + title: '💨 باد شدید', + description: `سرعت باد به ${toPersianDigits(Math.round(day.windSpeedMax))} کیلومتر بر ساعت می‌رسد. دریچه‌ها و پنجره‌ها را ببندید و سازه را بررسی کنید.`, + icon: Wind, + daysAhead, + date: day.date + }) + } + + // هشدار بارش قابل توجه (بیشتر از 10 میلی‌متر) + if (day.precipitation > 10) { + dayAlerts.push({ + type: 'info', + title: '🌧️ بارش قابل توجه', + description: `بارش ${toPersianDigits(Math.round(day.precipitation))} میلی‌متر پیش‌بینی شده. سیستم زهکشی را بررسی کنید و آبیاری را کاهش دهید.`, + icon: Droplets, + daysAhead, + date: day.date + }) + } + + // هشدار دمای پایین (بین 5 تا 10 درجه) - هشدار خفیف + if (day.tempMin >= 5 && day.tempMin < 10) { + dayAlerts.push({ + type: 'warning', + title: '🌡️ دمای پایین', + description: `دمای حداقل ${toPersianDigits(Math.round(day.tempMin))}°C پیش‌بینی شده. مراقب گیاهان حساس به سرما باشید.`, + icon: Thermometer, + daysAhead, + date: day.date + }) + } + + // هشدار دمای بالا (بین 30 تا 35 درجه) - هشدار خفیف + if (day.tempMax >= 30 && day.tempMax <= 35) { + dayAlerts.push({ + type: 'warning', + title: '🌡️ دمای بالا', + description: `دمای حداکثر ${toPersianDigits(Math.round(day.tempMax))}°C پیش‌بینی شده. تهویه را افزایش دهید و آبیاری را در ساعات صبح انجام دهید.`, + icon: Sun, + daysAhead, + date: day.date + }) + } + + // اگر هشداری برای این روز وجود دارد، به map اضافه کن + if (dayAlerts.length > 0) { + alertsMap.set(daysAhead, dayAlerts) + } + } + + return alertsMap + }, [weatherData]) + + if (alertsByDay.size === 0) { + return null + } + + // تبدیل map به array و مرتب‌سازی بر اساس daysAhead + const sortedDays = Array.from(alertsByDay.entries()).sort((a, b) => a[0] - b[0]) + + // تابع برای نمایش نام روزهای آینده + const getDayLabel = (daysAhead: number) => { + if (daysAhead === 1) return 'فردا' + if (daysAhead === 2) return 'پس فردا' + return `${toPersianDigits(daysAhead)} روز آینده` + } + + return ( +
+

+ + هشدارهای پیش‌بینی آب و هوا برای روزهای آینده +

+ + {sortedDays.map(([daysAhead, alerts]) => { + const dayName = getPersianDayName(weatherData.daily[daysAhead].date) + const dayLabel = getDayLabel(daysAhead) + + return ( +
+ {/* Header for the day */} +
+

+ روز {dayName} ({dayLabel}) +

+
+ + {/* Alerts for this day */} + {alerts.map((alert, index) => { + const IconComponent = alert.icon + return ( +
+
+ +
+

+ {alert.title} +

+

+ {alert.description} +

+
+
+
+ ) + })} +
+ ) + })} +
+ ) +} + diff --git a/src/components/daily-report/SummaryCard.tsx b/src/components/daily-report/SummaryCard.tsx index ce8eac6..f916428 100644 --- a/src/components/daily-report/SummaryCard.tsx +++ b/src/components/daily-report/SummaryCard.tsx @@ -1,7 +1,8 @@ import { TrendingUp, TrendingDown } from 'lucide-react' import { TemperatureGauge, HumidityGauge, LuxGauge, GasGauge } from '@/components/Gauges' import { MiniLineChart } from '@/components/MiniChart' -import { paramConfig, toPersianDigits } from './utils' +import { paramConfig } from '@/features/daily-report/utils' +import { toPersianDigits } from '@/lib/format/persian-digits' type SummaryCardProps = { param: string diff --git a/src/components/daily-report/SummaryTab.tsx b/src/components/daily-report/SummaryTab.tsx index a6a9004..eda7fb7 100644 --- a/src/components/daily-report/SummaryTab.tsx +++ b/src/components/daily-report/SummaryTab.tsx @@ -1,77 +1,151 @@ +import { useMemo, useState } from 'react' import { SummaryCard } from './SummaryCard' +import { WeatherData } from '@/features/weather' +import { GreenhouseForecastAlerts, getForecastAlertsCount } from './GreenhouseForecastAlerts' +import { Dialog } from '@/components/common/Dialog' +import { WeatherAlertBanner } from '@/components/alerts' +import { Loader2 } from 'lucide-react' type SummaryTabProps = { - temperature: { - current: number - min: number - max: number - data: number[] - } - humidity: { - current: number - min: number - max: number - data: number[] - } - soil: { - current: number - min: number - max: number - data: number[] - } - gas: { - current: number - min: number - max: number - data: number[] - } - lux: { - current: number - min: number - max: number - data: number[] - } + temperature: number[] + humidity: number[] + soil: number[] + gas: number[] + lux: number[] + forecastWeather?: WeatherData | null + forecastWeatherLoading?: boolean } -export function SummaryTab({ temperature, humidity, soil, gas, lux }: SummaryTabProps) { +export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeather, forecastWeatherLoading = false }: SummaryTabProps) { + const [isAlertsDialogOpen, setIsAlertsDialogOpen] = useState(false) + + const alertsCount = useMemo(() => { + return getForecastAlertsCount(forecastWeather ?? null) + }, [forecastWeather]) + // Memoized summary statistics for each parameter + const temperatureSummary = useMemo(() => { + if (temperature.length === 0) return { current: 0, min: 0, max: 0 } + return { + current: temperature.at(-1) ?? 0, + min: Math.min(...temperature), + max: Math.max(...temperature), + } + }, [temperature]) + + const humiditySummary = useMemo(() => { + if (humidity.length === 0) return { current: 0, min: 0, max: 0 } + return { + current: humidity.at(-1) ?? 0, + min: Math.min(...humidity), + max: Math.max(...humidity), + } + }, [humidity]) + + const soilSummary = useMemo(() => { + if (soil.length === 0) return { current: 0, min: 0, max: 0 } + return { + current: soil.at(-1) ?? 0, + min: Math.min(...soil), + max: Math.max(...soil), + } + }, [soil]) + + const gasSummary = useMemo(() => { + if (gas.length === 0) return { current: 0, min: 0, max: 0 } + return { + current: gas.at(-1) ?? 0, + min: Math.min(...gas), + max: Math.max(...gas), + } + }, [gas]) + + const luxSummary = useMemo(() => { + if (lux.length === 0) return { current: 0, min: 0, max: 0 } + return { + current: lux.at(-1) ?? 0, + min: Math.min(...lux), + max: Math.max(...lux), + } + }, [lux]) + return ( -
- - - - - -
+ <> + {/* Greenhouse Forecast Alerts Section */} + {forecastWeatherLoading ? ( +
+
+
+
+ +
+
+

+ در حال بارگذاری هشدارهای آب و هوایی... +

+

+ لطفاً صبر کنید +

+
+
+
+
+ ) : alertsCount > 0 && forecastWeather ? ( + setIsAlertsDialogOpen(true)} + /> + ) : null} + + {/* Summary Cards Grid */} +
+ + + + + +
+ + {/* Alerts Dialog */} + {forecastWeather && ( + setIsAlertsDialogOpen(false)} + title="هشدارهای پیش‌بینی آب و هوا" + > + + + )} + ) } diff --git a/src/components/daily-report/TimeRangeSelector.tsx b/src/components/daily-report/TimeRangeSelector.tsx index 442ab3d..6ad500e 100644 --- a/src/components/daily-report/TimeRangeSelector.tsx +++ b/src/components/daily-report/TimeRangeSelector.tsx @@ -1,5 +1,13 @@ -import { AlertTriangle } from 'lucide-react' -import { toPersianDigits, DataGap } from './utils' +import { useMemo } from 'react' +import { DataGap } from '@/features/daily-report/utils' +import { calculateSunTimes } from '@/lib/utils/sun-utils' +import { + TimeRangeHeader, + TimelineTrack, + TimelineSlider, + TimeLabel, + TimeRangeInfo, +} from './timeline' type TimeRangeSelectorProps = { startMinute: number // دقیقه از نیمه شب (0-1439) @@ -10,49 +18,6 @@ type TimeRangeSelectorProps = { dataGaps?: DataGap[] // گپ‌های داده } -// محاسبه زمان طلوع و غروب خورشید برای قم -// عرض جغرافیایی: 34.6416° شمالی، طول جغرافیایی: 50.8746° شرقی -function calculateSunTimes() { - const latitude = 34.6416 - const now = new Date() - const dayOfYear = Math.floor((now.getTime() - new Date(now.getFullYear(), 0, 0).getTime()) / 86400000) - - // محاسبه انحراف خورشید (Solar Declination) - const declination = -23.44 * Math.cos((2 * Math.PI / 365) * (dayOfYear + 10)) - - // محاسبه زاویه ساعتی طلوع (Hour Angle) - const latRad = latitude * Math.PI / 180 - const decRad = declination * Math.PI / 180 - const cosHourAngle = -Math.tan(latRad) * Math.tan(decRad) - - // در صورتی که خورشید طلوع/غروب می‌کند - if (Math.abs(cosHourAngle) <= 1) { - const hourAngle = Math.acos(cosHourAngle) * 180 / Math.PI - - // زمان طلوع و غروب به ساعت محلی (با دقیقه دقیق) - const sunriseDecimal = 12 - hourAngle / 15 + (50.8746 / 15 - 3.5) // تصحیح برای طول جغرافیایی و منطقه زمانی ایران - const sunsetDecimal = 12 + hourAngle / 15 + (50.8746 / 15 - 3.5) - - // تبدیل به ساعت و دقیقه - const sunriseHour = Math.floor(sunriseDecimal) - const sunriseMinute = Math.round((sunriseDecimal - sunriseHour) * 60) - - const sunsetHour = Math.floor(sunsetDecimal) - const sunsetMinute = Math.round((sunsetDecimal - sunsetHour) * 60) - - return { - sunrise: { hour: sunriseHour, minute: sunriseMinute, decimal: sunriseDecimal }, - sunset: { hour: sunsetHour, minute: sunsetMinute, decimal: sunsetDecimal } - } - } - - // مقادیر پیش‌فرض - return { - sunrise: { hour: 6, minute: 0, decimal: 6 }, - sunset: { hour: 18, minute: 0, decimal: 18 } - } -} - export function TimeRangeSelector({ startMinute, endMinute, @@ -61,205 +26,37 @@ export function TimeRangeSelector({ totalRecords, dataGaps = [] }: TimeRangeSelectorProps) { - const { sunrise, sunset } = calculateSunTimes() - - // تبدیل دقیقه به ساعت برای نمایش - const startHour = Math.floor(startMinute / 60) - const startMin = startMinute % 60 - const endHour = Math.floor(endMinute / 60) - const endMin = endMinute % 60 - - // محاسبه موقعیت دقیق با دقیقه (از 0 تا 24 ساعت) - const sunrisePosition = sunrise.decimal - const sunsetPosition = sunset.decimal + const sunTimes = useMemo(() => calculateSunTimes(), []) - // محاسبه درصد موقعیت برای نمایش (0 ساعت در راست، 1440 دقیقه در چپ) - const sunrisePercent = ((1439 - (sunrisePosition * 60)) / 1439) * 100 - const sunsetPercent = ((1439 - (sunsetPosition * 60)) / 1439) * 100 + const handleReset = () => { + onStartMinuteChange(0) + onEndMinuteChange(1439) // 23:59 + } return (
- {/* Header */} -
-
- محدوده زمانی - {dataGaps.length > 0 && ( -
- - {toPersianDigits(dataGaps.length)} گپ در داده‌ها -
- )} -
- -
+ {/* Timeline Selector */}
- {/* Track background */} -
- {/* Sunrise dashed line */} -
- - {/* Sunset dashed line */} -
- -
- طلوع {toPersianDigits(sunrise.hour.toString().padStart(2, '0'))}:{toPersianDigits(sunrise.minute.toString().padStart(2, '0'))} -
- -
-
- -
-
- - {/* Data gaps visualization */} - {dataGaps.map((gap, idx) => { - const gapStartPercent = ((1439 - gap.startMinute) / 1439) * 100 - const gapEndPercent = ((1439 - gap.endMinute) / 1439) * 100 - const gapWidth = gapStartPercent - gapEndPercent - const gapHours = Math.floor(gap.durationMinutes / 60) - const gapMins = gap.durationMinutes % 60 - - return ( -
- {/* Gap area */} -
- {/* Warning icon in gap */} -
- -
-
- - {/* Gap tooltip */} - {gapWidth > 5 && ( -
- گپ {toPersianDigits(gapHours)}:{toPersianDigits(gapMins.toString().padStart(2, '0'))} -
- )} -
- ) - })} - -
- غروب {toPersianDigits(sunset.hour.toString().padStart(2, '0'))}:{toPersianDigits(sunset.minute.toString().padStart(2, '0'))} -
- - {/* Hour markers inside track */} - {[0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22].map(hour => ( -
-
- - {toPersianDigits(hour.toString().padStart(2, '0'))} - -
-
-
- ))} -
- - {/* Start time label - above handle */} -
-
- {toPersianDigits(startHour.toString().padStart(2, '0'))}:{toPersianDigits(startMin.toString().padStart(2, '0'))} -
-
- - {/* End time label - above handle */} -
-
- {toPersianDigits(endHour.toString().padStart(2, '0'))}:{toPersianDigits(endMin.toString().padStart(2, '0'))} -
-
- - {/* Range inputs container */} -
- {/* Start time slider */} - { - const val = 1439 - Number(e.target.value) - if (val <= endMinute) onStartMinuteChange(val) - }} - className="absolute inset-0 w-full h-full appearance-none bg-transparent cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-20 [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:bg-emerald-500 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-20 [&::-moz-range-thumb]:rounded-sm [&::-moz-range-thumb]:bg-emerald-500 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:border-0 pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto" - /> - - {/* End time slider */} - { - const val = 1439 - Number(e.target.value) - if (val >= startMinute) onEndMinuteChange(val) - }} - className="absolute inset-0 w-full h-full appearance-none bg-transparent cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-20 [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:bg-rose-500 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-20 [&::-moz-range-thumb]:rounded-sm [&::-moz-range-thumb]:bg-rose-500 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:border-0 pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto" - /> -
+ + + + + +
- {/* Info section */} -
-
- - {toPersianDigits(Math.floor((endMinute - startMinute + 1) / 60))}:{toPersianDigits(((endMinute - startMinute + 1) % 60).toString().padStart(2, '0'))} - - بازه انتخاب شده -
-
- {totalRecords > 0 - ? <>{toPersianDigits(totalRecords)} رکورد - : <>بدون رکورد - } -
-
+
) } - diff --git a/src/components/daily-report/WeatherTab.tsx b/src/components/daily-report/WeatherTab.tsx index ffb45d9..3ad5501 100644 --- a/src/components/daily-report/WeatherTab.tsx +++ b/src/components/daily-report/WeatherTab.tsx @@ -1,80 +1,94 @@ -import { Loader2, AlertCircle, RefreshCw, MapPin, Droplets, Wind, Thermometer, Sun, CloudRain, Calendar as CalendarIcon, ChevronDown } from 'lucide-react' -import { WeatherData, toPersianDigits, getWeatherInfo, getPersianDayName, getGreenhouseAlerts } from '.' -import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/persian-date' +import { useState, useEffect, useCallback } from 'react' +import { RefreshCw, MapPin } from 'lucide-react' +import { WeatherData } from '@/features/weather' +import { fetchHistoricalWeather, fetchForecastWeather, isToday as checkIsToday, fetchLocationName } from '@/features/weather' +import { QOM_LAT, QOM_LON } from '@/features/weather/helpers' +import { TodayWeather } from './weather/TodayWeather' +import { HistoricalWeather } from './weather/HistoricalWeather' +import Loading from '@/components/Loading' +import { ErrorMessage } from '@/components/common' +import { Button } from '@/components/common/Button' type WeatherTabProps = { - loading: boolean - error: string | null - weatherData: WeatherData | null - onRetry: () => void - expandedDayIndex: number | null - onDayToggle: (index: number | null) => void - selectedDate: string | null // Persian date in format "yyyy/MM/dd" + selectedDate: string // Persian date in format "yyyy/MM/dd" } -export function WeatherTab({ - loading, - error, - weatherData, - onRetry, - expandedDayIndex, - onDayToggle, - selectedDate -}: WeatherTabProps) { - // Check if selected date is today by comparing Persian dates - const isToday = (() => { - if (!selectedDate) return true +export function WeatherTab({ selectedDate }: WeatherTabProps) { + const [weatherData, setWeatherData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [expandedDayIndex, setExpandedDayIndex] = useState(null) + const [locationName, setLocationName] = useState('در حال دریافت...') + + const loadWeather = useCallback(async () => { + // اگر قبلاً لود شده، دوباره لود نکن + if (weatherData) return + + setLoading(true) + setError(null) try { - // Get today's Persian date - const todayYear = getCurrentPersianYear() - const todayMonth = getCurrentPersianMonth() - const todayDay = getCurrentPersianDay() - const todayPersian = `${todayYear}/${String(todayMonth).padStart(2, '0')}/${String(todayDay).padStart(2, '0')}` + const isTodayDate = checkIsToday(selectedDate) - // Normalize selected date format - const [y, m, d] = selectedDate.split('/').map(s => s.trim()) - const normalizedSelected = `${y}/${String(Number(m)).padStart(2, '0')}/${String(Number(d)).padStart(2, '0')}` + // Load weather data and location name in parallel + const [weather, location] = await Promise.all([ + isTodayDate + ? fetchForecastWeather() + : fetchHistoricalWeather(selectedDate), + fetchLocationName(QOM_LAT, QOM_LON) + ]) - return normalizedSelected === todayPersian - } catch (e) { - console.error('Error checking if today:', e) - return true + setWeatherData(weather) + setLocationName(location) + } catch (error) { + console.error('Error loading weather:', error) + setError('خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید.') + // Set fallback location name on error + setLocationName('کهک قم، ایران') + } finally { + setLoading(false) } - })() + }, [selectedDate, weatherData]) + + // Reset weather data when selectedDate changes + useEffect(() => { + setWeatherData(null) + setError(null) + setExpandedDayIndex(null) + setLocationName('در حال دریافت...') + }, [selectedDate]) if (loading) { - return ( -
- -

در حال دریافت اطلاعات آب و هوا...

-
- ) + return } if (error) { return ( -
-
- -
-

{error}

- -
+ { + setWeatherData(null) + loadWeather() + }} + variant="primary" + icon={RefreshCw} + > + تلاش مجدد + + } + /> ) } if (!weatherData) { + // Trigger load when component is mounted (user clicked on weather tab) + loadWeather() return null } - const alerts = getGreenhouseAlerts(weatherData) + const isTodayDate = checkIsToday(selectedDate) return (
@@ -82,433 +96,24 @@ export function WeatherTab({
- قم، ایران + {locationName}
- {isToday ? 'پیش‌بینی هواشناسی برای مدیریت گلخانه' : 'وضعیت آب و هوای روز انتخاب شده'} + {isTodayDate ? 'پیش‌بینی هواشناسی برای مدیریت گلخانه' : 'وضعیت آب و هوای روز انتخاب شده'}
- {/* Greenhouse Alerts - Only for today */} - {isToday && alerts.length > 0 && ( -
-

🌱 هشدارها و توصیه‌های گلخانه

- {alerts.map((alert, index) => ( -
-

{alert.title}

-

{alert.description}

-
- ))} -
- )} - - {/* Today's Status Card */} -
-
- {/* Current Weather Header - Only for today */} - {isToday && ( -
-
-
-

🌡️ الان

-
- - {toPersianDigits(Math.round(weatherData.current.temperature))} - - درجه -
-
-
- {(() => { - const IconComponent = getWeatherInfo(weatherData.current.weatherCode).icon - return - })()} -

{getWeatherInfo(weatherData.current.weatherCode).description}

-
-
-
- )} - - {/* Past Date Header */} - {!isToday && ( -
-
-

📅 وضعیت آب و هوای روز

-

{selectedDate}

-
-
- )} - - {/* Status Grid */} -
- {/* Temperature Card */} -
35 ? 'bg-red-100 border-2 border-red-300' : - 'bg-green-100 border-2 border-green-300' - }`}> -
-
35 ? 'bg-red-500' : - 'bg-green-500' - }`}> - -
-
-

{isToday ? 'دمای امروز' : 'دمای روز'}

-

35 ? 'text-red-600' : - 'text-green-600' - }`}> - {weatherData.daily[0]?.tempMin < 5 ? '❄️ سرد!' : - weatherData.daily[0]?.tempMax > 35 ? '🔥 گرم!' : - '✅ مناسب'} -

-
-
-
-
-

🌙 شب

-

{toPersianDigits(Math.round(weatherData.daily[0]?.tempMin || 0))}°

-
-
-
-

☀️ روز

-

{toPersianDigits(Math.round(weatherData.daily[0]?.tempMax || 0))}°

-
-
-
- - {/* Rain Card */} - {isToday ? ( - /* Forecast: احتمال بارش */ -
60 ? 'bg-blue-100 border-2 border-blue-300' : - (weatherData.daily[0]?.precipitationProbability || 0) > 30 ? 'bg-sky-50 border-2 border-sky-200' : - 'bg-amber-50 border-2 border-amber-200' - }`}> -
-
60 ? 'bg-blue-500' : - (weatherData.daily[0]?.precipitationProbability || 0) > 30 ? 'bg-sky-400' : - 'bg-amber-400' - }`}> - {(weatherData.daily[0]?.precipitationProbability || 0) > 30 ? - : - - } -
-
-

بارش

-

60 ? 'text-blue-600' : - (weatherData.daily[0]?.precipitationProbability || 0) > 30 ? 'text-sky-600' : - 'text-amber-600' - }`}> - {(weatherData.daily[0]?.precipitationProbability || 0) > 60 ? '🌧️ باران می‌آید' : - (weatherData.daily[0]?.precipitationProbability || 0) > 30 ? '🌦️ شاید ببارد' : - '☀️ خشک است'} -

-
-
-
-

{toPersianDigits(weatherData.daily[0]?.precipitationProbability || 0)}%

-

احتمال بارش

-
-
- ) : ( - /* Historical: میزان بارش واقعی */ -
5 ? 'bg-blue-100 border-2 border-blue-300' : - (weatherData.daily[0]?.precipitation || 0) > 0 ? 'bg-sky-50 border-2 border-sky-200' : - 'bg-amber-50 border-2 border-amber-200' - }`}> -
-
5 ? 'bg-blue-500' : - (weatherData.daily[0]?.precipitation || 0) > 0 ? 'bg-sky-400' : - 'bg-amber-400' - }`}> - {(weatherData.daily[0]?.precipitation || 0) > 0 ? - : - - } -
-
-

بارش

-

5 ? 'text-blue-600' : - (weatherData.daily[0]?.precipitation || 0) > 0 ? 'text-sky-600' : - 'text-amber-600' - }`}> - {(weatherData.daily[0]?.precipitation || 0) > 5 ? '🌧️ بارش زیاد' : - (weatherData.daily[0]?.precipitation || 0) > 0 ? '🌦️ بارش کم' : - '☀️ بدون بارش'} -

-
-
-
-

{toPersianDigits((weatherData.daily[0]?.precipitation || 0).toFixed(1))}

-

میلی‌متر بارش

-
-
- )} - - {/* Sunlight Card */} -
-
-
- -
-
-

نور آفتاب

-

- {(weatherData.daily[0]?.sunshineDuration || 0) / 3600 > 8 ? '☀️ آفتاب زیاد' : - (weatherData.daily[0]?.sunshineDuration || 0) / 3600 > 4 ? '🌤️ آفتاب متوسط' : - '☁️ کم‌آفتاب'} -

-
-
-
-
-

{toPersianDigits(Math.round((weatherData.daily[0]?.sunshineDuration || 0) / 3600))}

-

ساعت آفتاب

-
-
-

{toPersianDigits(Math.round(weatherData.daily[0]?.uvIndexMax || 0))}

-

شاخص UV

-
-
-
- - {/* Wind & Humidity Card */} -
-
-
- -
-
-

باد و رطوبت

-

- {(weatherData.daily[0]?.windSpeedMax || 0) > 40 ? '💨 باد شدید!' : - (weatherData.daily[0]?.windSpeedMax || 0) > 20 ? '🍃 وزش باد' : - '😌 آرام'} -

-
-
-
-
-

{toPersianDigits(Math.round(weatherData.daily[0]?.windSpeedMax || 0))}

-

کیلومتر/ساعت باد

-
-
-

{toPersianDigits(weatherData.current.humidity)}%

-

رطوبت هوا

-
-
-
-
-
- - {/* Hourly Forecast - Only for today */} - {isToday && ( -
-
-

- 🕐 وضعیت ساعت به ساعت امروز -

-
- -
-
-
- {weatherData.hourly.map((hour) => { - const hourNum = new Date(hour.time).getHours() - const isNow = hourNum === new Date().getHours() - const IconComponent = getWeatherInfo(hour.weatherCode).icon - const isHot = hour.temperature > 35 - const isCold = hour.temperature < 10 - - return ( -
-

- {isNow ? '⏰ الان' : `${toPersianDigits(hourNum)}:۰۰`} -

- -
- -
- -

- {toPersianDigits(Math.round(hour.temperature))}° -

- -
- - {toPersianDigits(hour.humidity)}% -
- - {hour.precipitation > 0 && ( -
- 🌧️ {toPersianDigits(hour.precipitation.toFixed(1))} -
- )} -
- ) - })} -
-
-

👈 برای دیدن ساعت‌های بیشتر به چپ بکشید

-
-
- )} -
- - {/* 7-Day Forecast - Only for today */} - {isToday && ( -
-

- - پیش‌بینی ۷ روز آینده -

-
- {weatherData.daily.map((day, index) => { - const weatherInfo = getWeatherInfo(day.weatherCode) - const IconComponent = weatherInfo.icon - const isToday = index === 0 - const hasFrost = day.tempMin < 5 - const hasHeat = day.tempMax > 35 - const isExpanded = expandedDayIndex === index - - return ( -
- - - {isExpanded && ( -
-
-
-
- - دما -
-

{toPersianDigits(Math.round(day.tempMax))}°

-

حداکثر

-

{toPersianDigits(Math.round(day.tempMin))}°

-

حداقل

-
-
-
- - بارش -
-

{toPersianDigits(day.precipitationProbability)}%

-

احتمال

-

{toPersianDigits(day.precipitation.toFixed(1))}

-

میلی‌متر

-
-
-
- - ساعات آفتابی -
-

{toPersianDigits(Math.round(day.sunshineDuration / 3600))}

-

ساعت

-

{toPersianDigits(Math.round(day.uvIndexMax))}

-

UV Index

-
-
-
- - باد -
-

{toPersianDigits(Math.round(day.windSpeedMax))}

-

کیلومتر/ساعت

-
-
-
- )} -
- ) - })} -
-
+ {isTodayDate ? ( + + ) : ( + )}
) diff --git a/src/components/daily-report/index.ts b/src/components/daily-report/index.ts index 1c93717..cef920f 100644 --- a/src/components/daily-report/index.ts +++ b/src/components/daily-report/index.ts @@ -1,10 +1,9 @@ +// Daily report UI components only export { SummaryCard } from './SummaryCard' export { SummaryTab } from './SummaryTab' export { TimeRangeSelector } from './TimeRangeSelector' export { ChartsTab } from './ChartsTab' export { WeatherTab } from './WeatherTab' export { AnalysisTab } from './AnalysisTab' -export * from './types' -export * from './utils' -export * from './weather-helpers' +export { GreenhouseForecastAlerts } from './GreenhouseForecastAlerts' diff --git a/src/components/daily-report/timeline/DataGapMarker.tsx b/src/components/daily-report/timeline/DataGapMarker.tsx new file mode 100644 index 0000000..412127b --- /dev/null +++ b/src/components/daily-report/timeline/DataGapMarker.tsx @@ -0,0 +1,47 @@ +import { AlertTriangle } from 'lucide-react' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { DataGap } from '@/features/daily-report/utils' +import { minuteToPercent } from '@/lib/utils/time-utils' + +type DataGapMarkerProps = { + gap: DataGap + index: number +} + +export function DataGapMarker({ gap, index }: DataGapMarkerProps) { + const gapStartPercent = minuteToPercent(gap.startMinute) + const gapEndPercent = minuteToPercent(gap.endMinute) + const gapWidth = gapStartPercent - gapEndPercent + const gapHours = Math.floor(gap.durationMinutes / 60) + const gapMins = gap.durationMinutes % 60 + + return ( +
+ {/* Gap area */} +
+ {/* Warning icon in gap */} +
+ +
+
+ + {/* Gap tooltip */} + {gapWidth > 5 && ( +
+ گپ {toPersianDigits(gapHours)}:{toPersianDigits(gapMins.toString().padStart(2, '0'))} +
+ )} +
+ ) +} + diff --git a/src/components/daily-report/timeline/DataGapsOverlay.tsx b/src/components/daily-report/timeline/DataGapsOverlay.tsx new file mode 100644 index 0000000..761c590 --- /dev/null +++ b/src/components/daily-report/timeline/DataGapsOverlay.tsx @@ -0,0 +1,19 @@ +import { DataGap } from '@/features/daily-report/utils' +import { DataGapMarker } from './DataGapMarker' + +type DataGapsOverlayProps = { + dataGaps: DataGap[] +} + +export function DataGapsOverlay({ dataGaps }: DataGapsOverlayProps) { + if (dataGaps.length === 0) return null + + return ( + <> + {dataGaps.map((gap, idx) => ( + + ))} + + ) +} + diff --git a/src/components/daily-report/timeline/SunTimeLabel.tsx b/src/components/daily-report/timeline/SunTimeLabel.tsx new file mode 100644 index 0000000..16eb2c3 --- /dev/null +++ b/src/components/daily-report/timeline/SunTimeLabel.tsx @@ -0,0 +1,26 @@ +import { formatSunTimeLabel, minuteToPercent } from '@/lib/utils/time-utils' + +type SunTimeLabelProps = { + hour: number + minute: number + decimal: number + label: 'طلوع' | 'غروب' + position?: 'top' | 'bottom' +} + +export function SunTimeLabel({ hour, minute, decimal, label, position = 'bottom' }: SunTimeLabelProps) { + const percent = minuteToPercent(decimal * 60) + const timeStr = formatSunTimeLabel(label, hour, minute) + + return ( +
+ {timeStr} +
+ ) +} + diff --git a/src/components/daily-report/timeline/TimeLabel.tsx b/src/components/daily-report/timeline/TimeLabel.tsx new file mode 100644 index 0000000..0a13d4f --- /dev/null +++ b/src/components/daily-report/timeline/TimeLabel.tsx @@ -0,0 +1,39 @@ +import { memo, useMemo } from 'react' +import { formatTimeLabel, minuteToHours, minuteToPercent } from '@/lib/utils/time-utils' + +type TimeLabelProps = { + minute: number + variant?: 'start' | 'end' + className?: string +} + +const variantStyles = { + start: 'bg-emerald-500', + end: 'bg-rose-500', +} + +function TimeLabelComponent({ minute, variant = 'start', className }: TimeLabelProps) { + const { hour, min, timeStr, percent } = useMemo(() => { + const { hour, minute: min } = minuteToHours(minute) + const timeStr = formatTimeLabel(hour, min) + const percent = minuteToPercent(minute) + return { hour, min, timeStr, percent } + }, [minute]) + + return ( +
+
+ {timeStr} +
+
+ ) +} + +export const TimeLabel = memo(TimeLabelComponent) + diff --git a/src/components/daily-report/timeline/TimeRangeHeader.tsx b/src/components/daily-report/timeline/TimeRangeHeader.tsx new file mode 100644 index 0000000..15372a1 --- /dev/null +++ b/src/components/daily-report/timeline/TimeRangeHeader.tsx @@ -0,0 +1,34 @@ +import { AlertTriangle } from 'lucide-react' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { Badge } from '@/components/common' +import { DataGap } from '@/features/daily-report/utils' + +type TimeRangeHeaderProps = { + dataGaps?: DataGap[] + onReset: () => void +} + +export function TimeRangeHeader({ dataGaps = [], onReset }: TimeRangeHeaderProps) { + return ( +
+
+ محدوده زمانی + {dataGaps.length > 0 && ( + + {toPersianDigits(dataGaps.length)} گپ در داده‌ها + + )} +
+ +
+ ) +} + diff --git a/src/components/daily-report/timeline/TimeRangeInfo.tsx b/src/components/daily-report/timeline/TimeRangeInfo.tsx new file mode 100644 index 0000000..c906c28 --- /dev/null +++ b/src/components/daily-report/timeline/TimeRangeInfo.tsx @@ -0,0 +1,30 @@ +import { toPersianDigits } from '@/lib/format/persian-digits' +import { calculateDuration } from '@/lib/utils/time-utils' + +type TimeRangeInfoProps = { + startMinute: number + endMinute: number + totalRecords: number +} + +export function TimeRangeInfo({ startMinute, endMinute, totalRecords }: TimeRangeInfoProps) { + const { hour, minute } = calculateDuration(startMinute, endMinute) + + return ( +
+
+ + {toPersianDigits(hour)}:{toPersianDigits(minute.toString().padStart(2, '0'))} + + بازه انتخاب شده +
+
+ {totalRecords > 0 + ? <>{toPersianDigits(totalRecords)} رکورد + : <>بدون رکورد + } +
+
+ ) +} + diff --git a/src/components/daily-report/timeline/TimelineHourMarkers.tsx b/src/components/daily-report/timeline/TimelineHourMarkers.tsx new file mode 100644 index 0000000..9da6266 --- /dev/null +++ b/src/components/daily-report/timeline/TimelineHourMarkers.tsx @@ -0,0 +1,25 @@ +import { toPersianDigits } from '@/lib/format/persian-digits' + +const HOURS_TO_MARK = [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22] + +export function TimelineHourMarkers() { + return ( + <> + {HOURS_TO_MARK.map(hour => ( +
+
+ + {toPersianDigits(hour.toString().padStart(2, '0'))} + +
+
+
+ ))} + + ) +} + diff --git a/src/components/daily-report/timeline/TimelineSlider.tsx b/src/components/daily-report/timeline/TimelineSlider.tsx new file mode 100644 index 0000000..9323916 --- /dev/null +++ b/src/components/daily-report/timeline/TimelineSlider.tsx @@ -0,0 +1,109 @@ +import { memo, useState, useEffect, useRef } from 'react' +import { useDebounce } from '@/hooks/useDebounce' + +type TimelineSliderProps = { + startMinute: number + endMinute: number + onStartMinuteChange: (minute: number) => void + onEndMinuteChange: (minute: number) => void +} + +function TimelineSliderComponent({ + startMinute, + endMinute, + onStartMinuteChange, + onEndMinuteChange, +}: TimelineSliderProps) { + // State محلی برای input values (برای نمایش فوری) + const [localStartValue, setLocalStartValue] = useState(1439 - startMinute) + const [localEndValue, setLocalEndValue] = useState(1439 - endMinute) + + // Refs برای track کردن تغییرات props و نگه داشتن مقادیر valid + const prevStartMinuteRef = useRef(startMinute) + const prevEndMinuteRef = useRef(endMinute) + const endMinuteRef = useRef(endMinute) + const startMinuteRef = useRef(startMinute) + + // Sync refs with props + useEffect(() => { + endMinuteRef.current = endMinute + startMinuteRef.current = startMinute + }, [startMinute, endMinute]) + + // Sync local state with props (فقط وقتی props از بیرون تغییر می‌کنن) + useEffect(() => { + if (prevStartMinuteRef.current !== startMinute) { + setLocalStartValue(1439 - startMinute) + prevStartMinuteRef.current = startMinute + } + }, [startMinute]) + + useEffect(() => { + if (prevEndMinuteRef.current !== endMinute) { + setLocalEndValue(1439 - endMinute) + prevEndMinuteRef.current = endMinute + } + }, [endMinute]) + + // Debounce values + const debouncedStartValue = useDebounce(localStartValue, 600) + const debouncedEndValue = useDebounce(localEndValue, 600) + + // Update parent when debounced values change (فقط وقتی از user input باشه) + useEffect(() => { + const val = 1439 - debouncedStartValue + const currentEndMinute = endMinuteRef.current + const currentStartMinute = startMinuteRef.current + // فقط اگه مقدار debounced با prop فعلی متفاوت باشه و valid باشه + if (val !== currentStartMinute && val <= currentEndMinute) { + onStartMinuteChange(val) + } + }, [debouncedStartValue, onStartMinuteChange]) + + useEffect(() => { + const val = 1439 - debouncedEndValue + const currentStartMinute = startMinuteRef.current + const currentEndMinute = endMinuteRef.current + // فقط اگه مقدار debounced با prop فعلی متفاوت باشه و valid باشه + if (val !== currentEndMinute && val >= currentStartMinute) { + onEndMinuteChange(val) + } + }, [debouncedEndValue, onEndMinuteChange]) + + const handleStartChange = (e: React.ChangeEvent) => { + const inputValue = Number(e.target.value) + setLocalStartValue(inputValue) + } + + const handleEndChange = (e: React.ChangeEvent) => { + const inputValue = Number(e.target.value) + setLocalEndValue(inputValue) + } + + return ( +
+ {/* Start time slider */} + + + {/* End time slider */} + +
+ ) +} + +export const TimelineSlider = memo(TimelineSliderComponent) + diff --git a/src/components/daily-report/timeline/TimelineTrack.tsx b/src/components/daily-report/timeline/TimelineTrack.tsx new file mode 100644 index 0000000..b9086f9 --- /dev/null +++ b/src/components/daily-report/timeline/TimelineTrack.tsx @@ -0,0 +1,73 @@ +import { memo, useMemo } from 'react' +import { DataGap } from '@/features/daily-report/utils' +import { minuteToPercent } from '@/lib/utils/time-utils' +import { DataGapsOverlay } from './DataGapsOverlay' +import { TimelineHourMarkers } from './TimelineHourMarkers' +import { SunTimeLabel } from './SunTimeLabel' + +type SunTimes = { + sunrise: { hour: number; minute: number; decimal: number } + sunset: { hour: number; minute: number; decimal: number } +} + +type TimelineTrackProps = { + sunTimes: SunTimes + dataGaps?: DataGap[] +} + +function TimelineTrackComponent({ sunTimes, dataGaps = [] }: TimelineTrackProps) { + const { sunrisePercent, sunsetPercent } = useMemo(() => ({ + sunrisePercent: minuteToPercent(sunTimes.sunrise.decimal * 60), + sunsetPercent: minuteToPercent(sunTimes.sunset.decimal * 60), + }), [sunTimes.sunrise.decimal, sunTimes.sunset.decimal]) + + return ( +
+ {/* Sunrise dashed line */} +
+ + {/* Sunset dashed line */} +
+ + {/* Sun time labels */} + + + + {/* Night/day background areas */} +
+
+ + {/* Data gaps visualization */} + + + {/* Hour markers */} + +
+ ) +} + +export const TimelineTrack = memo(TimelineTrackComponent) diff --git a/src/components/daily-report/timeline/index.ts b/src/components/daily-report/timeline/index.ts new file mode 100644 index 0000000..0da3c36 --- /dev/null +++ b/src/components/daily-report/timeline/index.ts @@ -0,0 +1,10 @@ +export { TimeRangeHeader } from './TimeRangeHeader' +export { TimeLabel } from './TimeLabel' +export { SunTimeLabel } from './SunTimeLabel' +export { TimelineHourMarkers } from './TimelineHourMarkers' +export { DataGapMarker } from './DataGapMarker' +export { DataGapsOverlay } from './DataGapsOverlay' +export { TimelineSlider } from './TimelineSlider' +export { TimelineTrack } from './TimelineTrack' +export { TimeRangeInfo } from './TimeRangeInfo' + diff --git a/src/components/daily-report/weather/HistoricalWeather.tsx b/src/components/daily-report/weather/HistoricalWeather.tsx new file mode 100644 index 0000000..26b9311 --- /dev/null +++ b/src/components/daily-report/weather/HistoricalWeather.tsx @@ -0,0 +1,32 @@ +import { WeatherData } from '@/features/weather' +import { TemperatureCard, PrecipitationHistoricalCard, SunlightCard, WindHumidityCard } from './WeatherCards' + +type HistoricalWeatherProps = { + weatherData: WeatherData + selectedDate: string +} + +export function HistoricalWeather({ weatherData, selectedDate }: HistoricalWeatherProps) { + return ( +
+ {/* Past Date Header */} +
+
+
+

📅 وضعیت آب و هوای روز

+

{selectedDate}

+
+
+ + {/* Status Grid */} +
+ + + + +
+
+
+ ) +} + diff --git a/src/components/daily-report/weather/TodayWeather.tsx b/src/components/daily-report/weather/TodayWeather.tsx new file mode 100644 index 0000000..e13af6a --- /dev/null +++ b/src/components/daily-report/weather/TodayWeather.tsx @@ -0,0 +1,268 @@ +import { Calendar as CalendarIcon, ChevronDown } from 'lucide-react' +import { WeatherData } from '@/features/weather' +import { getWeatherInfo, getPersianDayName } from '@/features/daily-report/utils' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { TemperatureCard, PrecipitationForecastCard, SunlightCard, WindHumidityCard } from './WeatherCards' +import { getGreenhouseAlerts } from '@/features/weather' + +type TodayWeatherProps = { + weatherData: WeatherData + expandedDayIndex: number | null + onDayToggle: (index: number | null) => void +} + +export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: TodayWeatherProps) { + const alerts = getGreenhouseAlerts(weatherData) + + return ( +
+ {/* Greenhouse Alerts */} + {alerts.length > 0 && ( +
+

🌱 هشدارها و توصیه‌های گلخانه

+ {alerts.map((alert, index) => { + const IconComponent = alert.icon + return ( +
+
+ +
+

{alert.title}

+

{alert.description}

+
+
+
+ ) + })} +
+ )} + + {/* Current Weather Header */} +
+
+
+
+

🌡️ الان

+
+ + {toPersianDigits(Math.round(weatherData.current.temperature))} + + درجه +
+
+
+ {(() => { + const IconComponent = getWeatherInfo(weatherData.current.weatherCode).icon + return + })()} +

{getWeatherInfo(weatherData.current.weatherCode).description}

+
+
+
+ + {/* Status Grid */} +
+ + + + +
+
+ + {/* Hourly Forecast */} +
+
+

+ 🕐 وضعیت ساعت به ساعت امروز +

+
+ +
+
+
+ {weatherData.hourly.map((hour) => { + const hourNum = new Date(hour.time).getHours() + const isNow = hourNum === new Date().getHours() + const IconComponent = getWeatherInfo(hour.weatherCode).icon + const isHot = hour.temperature > 35 + const isCold = hour.temperature < 10 + + return ( +
+

+ {isNow ? '⏰ الان' : `${toPersianDigits(hourNum)}:۰۰`} +

+ +
+ +
+ +

+ {toPersianDigits(Math.round(hour.temperature))}° +

+ +
+ {toPersianDigits(hour.humidity)}% +
+ + {hour.precipitation > 0 && ( +
+ 🌧️ {toPersianDigits(hour.precipitation.toFixed(1))} +
+ )} +
+ ) + })} +
+
+

👈 برای دیدن ساعت‌های بیشتر به چپ بکشید

+
+
+ + {/* 7-Day Forecast */} +
+

+ + پیش‌بینی ۷ روز آینده +

+
+ {weatherData.daily.map((day, index) => { + const weatherInfo = getWeatherInfo(day.weatherCode) + const IconComponent = weatherInfo.icon + const isToday = index === 0 + const hasFrost = day.tempMin < 5 + const hasHeat = day.tempMax > 35 + const isExpanded = expandedDayIndex === index + + return ( +
+ + + {isExpanded && ( +
+
+
+
+ دما +
+

{toPersianDigits(Math.round(day.tempMax))}°

+

حداکثر

+

{toPersianDigits(Math.round(day.tempMin))}°

+

حداقل

+
+
+
+ بارش +
+

{toPersianDigits(day.precipitationProbability)}%

+

احتمال

+

{toPersianDigits(day.precipitation.toFixed(1))}

+

میلی‌متر

+
+
+
+ ساعات آفتابی +
+

{toPersianDigits(Math.round(day.sunshineDuration / 3600))}

+

ساعت

+

{toPersianDigits(Math.round(day.uvIndexMax))}

+

UV Index

+
+
+
+ باد +
+

{toPersianDigits(Math.round(day.windSpeedMax))}

+

کیلومتر/ساعت

+
+
+
+ )} +
+ ) + })} +
+
+
+ ) +} + diff --git a/src/components/daily-report/weather/WeatherCards.tsx b/src/components/daily-report/weather/WeatherCards.tsx new file mode 100644 index 0000000..eb1c605 --- /dev/null +++ b/src/components/daily-report/weather/WeatherCards.tsx @@ -0,0 +1,227 @@ +import { Thermometer, Sun, CloudRain, Wind } from 'lucide-react' +import { WeatherData } from '@/features/weather' +import { toPersianDigits } from '@/lib/format/persian-digits' + +type WeatherCardsProps = { + weatherData: WeatherData + isToday: boolean +} + +/** + * Temperature Card - مشترک بین امروز و گذشته + */ +export function TemperatureCard({ weatherData, isToday }: WeatherCardsProps) { + const tempMin = weatherData.daily[0]?.tempMin || 0 + const tempMax = weatherData.daily[0]?.tempMax || 0 + + const isCold = tempMin < 5 + const isHot = tempMax > 35 + + return ( +
+
+
+ +
+
+

{isToday ? 'دمای امروز' : 'دمای روز'}

+

+ {isCold ? '❄️ سرد!' : + isHot ? '🔥 گرم!' : + '✅ مناسب'} +

+
+
+
+
+

🌙 شب

+

{toPersianDigits(Math.round(tempMin))}°

+
+
+
+

☀️ روز

+

{toPersianDigits(Math.round(tempMax))}°

+
+
+
+ ) +} + +/** + * Precipitation Card - برای امروز (احتمال بارش) + */ +export function PrecipitationForecastCard({ weatherData }: { weatherData: WeatherData }) { + const probability = weatherData.daily[0]?.precipitationProbability || 0 + + const isHigh = probability > 60 + const isMedium = probability > 30 + + return ( +
+
+
+ {isMedium ? + : + + } +
+
+

بارش

+

+ {isHigh ? '🌧️ باران می‌آید' : + isMedium ? '🌦️ شاید ببارد' : + '☀️ خشک است'} +

+
+
+
+

{toPersianDigits(probability)}%

+

احتمال بارش

+
+
+ ) +} + +/** + * Precipitation Card - برای روزهای گذشته (میزان بارش واقعی) + */ +export function PrecipitationHistoricalCard({ weatherData }: { weatherData: WeatherData }) { + const precipitation = weatherData.daily[0]?.precipitation || 0 + + const isHigh = precipitation > 5 + const hasPrecipitation = precipitation > 0 + + return ( +
+
+
+ {hasPrecipitation ? + : + + } +
+
+

بارش

+

+ {isHigh ? '🌧️ بارش زیاد' : + hasPrecipitation ? '🌦️ بارش کم' : + '☀️ بدون بارش'} +

+
+
+
+

{toPersianDigits(precipitation.toFixed(1))}

+

میلی‌متر بارش

+
+
+ ) +} + +/** + * Sunlight Card - مشترک بین امروز و گذشته + */ +export function SunlightCard({ weatherData }: { weatherData: WeatherData }) { + const sunshineHours = (weatherData.daily[0]?.sunshineDuration || 0) / 3600 + const uvIndex = weatherData.daily[0]?.uvIndexMax || 0 + + return ( +
+
+
+ +
+
+

نور آفتاب

+

+ {sunshineHours > 8 ? '☀️ آفتاب زیاد' : + sunshineHours > 4 ? '🌤️ آفتاب متوسط' : + '☁️ کم‌آفتاب'} +

+
+
+
+
+

{toPersianDigits(Math.round(sunshineHours))}

+

ساعت آفتاب

+
+
+

{toPersianDigits(Math.round(uvIndex))}

+

شاخص UV

+
+
+
+ ) +} + +/** + * Wind & Humidity Card - مشترک بین امروز و گذشته + */ +export function WindHumidityCard({ weatherData }: { weatherData: WeatherData }) { + const windSpeed = weatherData.daily[0]?.windSpeedMax || 0 + const humidity = weatherData.current.humidity + + return ( +
+
+
+ +
+
+

باد و رطوبت

+

+ {windSpeed > 40 ? '💨 باد شدید!' : + windSpeed > 20 ? '🍃 وزش باد' : + '😌 آرام'} +

+
+
+
+
+

{toPersianDigits(Math.round(windSpeed))}

+

کیلومتر/ساعت باد

+
+
+

{toPersianDigits(humidity)}%

+

رطوبت هوا

+
+
+
+ ) +} + diff --git a/src/components/forms/CodeInput.tsx b/src/components/forms/CodeInput.tsx new file mode 100644 index 0000000..558835a --- /dev/null +++ b/src/components/forms/CodeInput.tsx @@ -0,0 +1,80 @@ +'use client' + +import { useRef, KeyboardEvent, ClipboardEvent } from 'react' +import { cn } from '@/lib/utils' + +type CodeInputProps = { + length?: number + value: string[] + onChange: (value: string[]) => void + onComplete?: (code: string) => void + disabled?: boolean + className?: string +} + +export function CodeInput({ + length = 4, + value, + onChange, + onComplete, + disabled = false, + className +}: CodeInputProps) { + const inputRefs = useRef<(HTMLInputElement | null)[]>([]) + + const handleCodeChange = (index: number, inputValue: string) => { + if (!/^\d*$/.test(inputValue)) return + + const newCode = [...value] + newCode[index] = inputValue.slice(-1) + onChange(newCode) + + // Auto-focus next input + if (inputValue && index < length - 1) { + inputRefs.current[index + 1]?.focus() + } + + // Auto-submit when all fields are filled + if (newCode.every(c => c !== '') && newCode.join('').length === length) { + onComplete?.(newCode.join('')) + } + } + + const handleKeyDown = (index: number, e: KeyboardEvent) => { + if (e.key === 'Backspace' && !value[index] && index > 0) { + inputRefs.current[index - 1]?.focus() + } + } + + const handlePaste = (e: ClipboardEvent) => { + e.preventDefault() + const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, length) + if (pastedData.length === length) { + const newCode = pastedData.split('') + onChange(newCode) + inputRefs.current[length - 1]?.focus() + onComplete?.(pastedData) + } + } + + return ( +
+ {Array.from({ length }, (_, index) => ( + { inputRefs.current[index] = el }} + type="text" + inputMode="numeric" + maxLength={1} + value={value[index] || ''} + onChange={(e) => handleCodeChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + onPaste={index === 0 ? handlePaste : undefined} + className="w-14 h-14 text-center text-2xl font-bold rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200" + disabled={disabled} + /> + ))} +
+ ) +} + diff --git a/src/components/forms/FormInput.tsx b/src/components/forms/FormInput.tsx new file mode 100644 index 0000000..e58887f --- /dev/null +++ b/src/components/forms/FormInput.tsx @@ -0,0 +1,71 @@ +import { InputHTMLAttributes, LabelHTMLAttributes } from 'react' +import { cn } from '@/lib/utils' + +type FormInputProps = InputHTMLAttributes & { + label?: string + labelProps?: LabelHTMLAttributes + error?: string + helperText?: string + leftAddon?: React.ReactNode + rightAddon?: React.ReactNode +} + +export function FormInput({ + label, + labelProps, + error, + helperText, + leftAddon, + rightAddon, + className, + id, + ...props +}: FormInputProps) { + const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}` + + return ( +
+ {label && ( + + )} +
+ {leftAddon && ( +
+ {leftAddon} +
+ )} + + {rightAddon && ( +
+ {rightAddon} +
+ )} +
+ {error && ( +

{error}

+ )} + {helperText && !error && ( +

{helperText}

+ )} +
+ ) +} + diff --git a/src/components/forms/MobileInput.tsx b/src/components/forms/MobileInput.tsx new file mode 100644 index 0000000..197674e --- /dev/null +++ b/src/components/forms/MobileInput.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useRef, useEffect, useCallback, InputHTMLAttributes } from 'react' +import { cn } from '@/lib/utils' + +type MobileInputProps = Omit, 'onInput' | 'value'> & { + value: string + onValueChange: (value: string) => void +} + +export function MobileInput({ + value, + onValueChange, + className, + ...props +}: MobileInputProps) { + const inputRef = useRef(null) + + const handleInputChange = useCallback((e: React.FormEvent) => { + const inputValue = e.currentTarget.value + const digitsOnly = inputValue.replace(/\D/g, '') + onValueChange(digitsOnly) + }, [onValueChange]) + + useEffect(() => { + const checkAutoFill = () => { + setTimeout(() => { + if (inputRef.current) { + const filledMobile = inputRef.current.value + if (filledMobile && filledMobile !== value) { + handleInputChange({ currentTarget: { value: filledMobile } } as React.FormEvent) + } + } + }, 100) + } + + checkAutoFill() + window.addEventListener('load', checkAutoFill) + + return () => { + window.removeEventListener('load', checkAutoFill) + } + }, [value, handleInputChange]) + + return ( + + ) +} + diff --git a/src/components/forms/SearchInput.tsx b/src/components/forms/SearchInput.tsx new file mode 100644 index 0000000..9c78c8e --- /dev/null +++ b/src/components/forms/SearchInput.tsx @@ -0,0 +1,73 @@ +'use client' + +import { FormEvent, InputHTMLAttributes } from 'react' +import { Search, X } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Button } from '@/components/common/Button' + +type SearchInputProps = Omit, 'onSubmit'> & { + value: string + onValueChange: (value: string) => void + onSubmit?: (value: string) => void + onClear?: () => void + placeholder?: string + showClearButton?: boolean + showSearchButton?: boolean +} + +export function SearchInput({ + value, + onValueChange, + onSubmit, + onClear, + placeholder = 'جستجو...', + showClearButton = true, + showSearchButton = true, + className, + ...props +}: SearchInputProps) { + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + onSubmit?.(value) + } + + const handleClear = () => { + onValueChange('') + onClear?.() + } + + return ( + +
+ + onValueChange(e.target.value)} + placeholder={placeholder} + className="w-full pr-12 pl-4 py-3 rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200" + {...props} + /> + {showClearButton && value && ( + + )} +
+ {showSearchButton && ( + + )} + + ) +} + diff --git a/src/components/forms/index.ts b/src/components/forms/index.ts new file mode 100644 index 0000000..b98f3c0 --- /dev/null +++ b/src/components/forms/index.ts @@ -0,0 +1,5 @@ +export { MobileInput } from './MobileInput' +export { CodeInput } from './CodeInput' +export { SearchInput } from './SearchInput' +export { FormInput } from './FormInput' + diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..d6f2d49 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,29 @@ +// Common components +export * from './common' + +// Form components +export * from './forms' + +// Navigation components +export * from './navigation' + +// Card components +export * from './cards' + +// Alert components +export * from './alerts' + +// Settings components +export * from './settings' + +// Calendar components +export * from './calendar' + +// Utils components +export * from './utils' + +// Other components +export { default as Loading } from './Loading' +export { Skeleton, CardSkeleton } from './Loading' +export { LineChart, Panel } from './Charts' + diff --git a/src/components/navigation/DateNavigation.tsx b/src/components/navigation/DateNavigation.tsx new file mode 100644 index 0000000..6dd3194 --- /dev/null +++ b/src/components/navigation/DateNavigation.tsx @@ -0,0 +1,47 @@ +import { ChevronRight, ChevronLeft, Calendar as CalendarIcon } from 'lucide-react' +import { Button } from '@/components/common/Button' +import { toPersianDigits } from '@/lib/format/persian-digits' + +type DateNavigationProps = { + selectedDate: string + onPrevious: () => void + onNext: () => void + onCalendar: () => void + className?: string +} + +export function DateNavigation({ + selectedDate, + onPrevious, + onNext, + onCalendar, + className +}: DateNavigationProps) { + return ( +
+ +
+ ) +} + diff --git a/src/components/navigation/Pagination.tsx b/src/components/navigation/Pagination.tsx new file mode 100644 index 0000000..ef8ab6d --- /dev/null +++ b/src/components/navigation/Pagination.tsx @@ -0,0 +1,88 @@ +import { ChevronRight, ChevronLeft } from 'lucide-react' +import { cn } from '@/lib/utils' + +type PaginationProps = { + currentPage: number + totalPages: number + onPageChange: (page: number) => void + className?: string + maxVisiblePages?: number +} + +export function Pagination({ + currentPage, + totalPages, + onPageChange, + className, + maxVisiblePages = 5 +}: PaginationProps) { + if (totalPages <= 1) return null + + const getVisiblePages = () => { + if (totalPages <= maxVisiblePages) { + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + + if (currentPage <= 3) { + return Array.from({ length: maxVisiblePages }, (_, i) => i + 1) + } + + if (currentPage >= totalPages - 2) { + return Array.from({ length: maxVisiblePages }, (_, i) => totalPages - maxVisiblePages + i + 1) + } + + return Array.from({ length: maxVisiblePages }, (_, i) => currentPage - 2 + i) + } + + const visiblePages = getVisiblePages() + + return ( +
+ + +
+ {visiblePages.map((pageNum) => ( + + ))} +
+ + +
+ ) +} + diff --git a/src/components/navigation/index.ts b/src/components/navigation/index.ts new file mode 100644 index 0000000..05a9dd6 --- /dev/null +++ b/src/components/navigation/index.ts @@ -0,0 +1,3 @@ +export { DateNavigation } from './DateNavigation' +export { Pagination } from './Pagination' + diff --git a/src/components/settings/SettingsInputGroup.tsx b/src/components/settings/SettingsInputGroup.tsx new file mode 100644 index 0000000..a3e2f87 --- /dev/null +++ b/src/components/settings/SettingsInputGroup.tsx @@ -0,0 +1,56 @@ +import { FormInput } from '@/components/forms/FormInput' + +type SettingsInputGroupProps = { + label: string + minLabel: string + maxLabel: string + minValue: number + maxValue: number + onMinChange: (value: number) => void + onMaxChange: (value: number) => void + minUnit?: string + maxUnit?: string + minStep?: number + maxStep?: number + className?: string +} + +export function SettingsInputGroup({ + label, + minLabel, + maxLabel, + minValue, + maxValue, + onMinChange, + onMaxChange, + minUnit, + maxUnit, + minStep = 0.1, + maxStep = 0.1, + className +}: SettingsInputGroupProps) { + return ( +
+ +
+ onMinChange(parseFloat(e.target.value) || 0)} + rightAddon={minUnit && {minUnit}} + /> + onMaxChange(parseFloat(e.target.value) || 0)} + rightAddon={maxUnit && {maxUnit}} + /> +
+
+ ) +} + diff --git a/src/components/settings/SettingsSection.tsx b/src/components/settings/SettingsSection.tsx new file mode 100644 index 0000000..97c0546 --- /dev/null +++ b/src/components/settings/SettingsSection.tsx @@ -0,0 +1,37 @@ +import { ReactNode } from 'react' +import { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type SettingsSectionProps = { + icon: LucideIcon + title: string + children: ReactNode + iconGradient?: string + className?: string +} + +export function SettingsSection({ + icon: Icon, + title, + children, + iconGradient = 'from-gray-500 to-gray-600', + className +}: SettingsSectionProps) { + return ( +
+
+
+ +
+

+ {title} +

+
+ {children} +
+ ) +} + diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts new file mode 100644 index 0000000..b8d85c6 --- /dev/null +++ b/src/components/settings/index.ts @@ -0,0 +1,3 @@ +export { SettingsInputGroup } from './SettingsInputGroup' +export { SettingsSection } from './SettingsSection' + diff --git a/src/components/utils/ConfirmDialog.tsx b/src/components/utils/ConfirmDialog.tsx new file mode 100644 index 0000000..2f2aad2 --- /dev/null +++ b/src/components/utils/ConfirmDialog.tsx @@ -0,0 +1,84 @@ +'use client' + +import { useState, useCallback } from 'react' +import { Modal } from '@/components/common/Modal' +import { Button } from '@/components/common/Button' + +type ConfirmDialogOptions = { + title?: string + message: string + confirmText?: string + cancelText?: string + variant?: 'default' | 'danger' +} + +type ConfirmDialogHook = { + confirm: (options: ConfirmDialogOptions) => Promise +} + +export function useConfirmDialog(): ConfirmDialogHook { + const [isOpen, setIsOpen] = useState(false) + const [options, setOptions] = useState({ message: '' }) + const [resolve, setResolve] = useState<((value: boolean) => void) | null>(null) + + const confirm = useCallback((opts: ConfirmDialogOptions): Promise => { + return new Promise((res) => { + setOptions(opts) + setIsOpen(true) + setResolve(() => res) + }) + }, []) + + const handleConfirm = () => { + setIsOpen(false) + resolve?.(true) + setResolve(null) + } + + const handleCancel = () => { + setIsOpen(false) + resolve?.(false) + setResolve(null) + } + + const ConfirmDialogComponent = () => ( + +
+

{options.message}

+
+ + +
+
+
+ ) + + return { + confirm, + ConfirmDialog: ConfirmDialogComponent + } as ConfirmDialogHook & { ConfirmDialog: React.ComponentType } +} + +// Simple confirm function for direct use (fallback to window.confirm) +export async function confirmDialog(options: ConfirmDialogOptions): Promise { + return new Promise((resolve) => { + const result = window.confirm(options.message) + resolve(result) + }) +} + diff --git a/src/components/utils/ResendButton.tsx b/src/components/utils/ResendButton.tsx new file mode 100644 index 0000000..1947266 --- /dev/null +++ b/src/components/utils/ResendButton.tsx @@ -0,0 +1,49 @@ +'use client' + +import { RotateCcw } from 'lucide-react' +import { cn } from '@/lib/utils' + +type ResendButtonProps = { + canResend: boolean + cooldown: number + onResend: () => void + loading?: boolean + className?: string +} + +export function ResendButton({ + canResend, + cooldown, + onResend, + loading = false, + className +}: ResendButtonProps) { + const formatCooldown = (seconds: number) => { + const minutes = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${minutes}:${String(secs).padStart(2, '0')}` + } + + return ( + + ) +} + diff --git a/src/components/utils/index.ts b/src/components/utils/index.ts new file mode 100644 index 0000000..5510558 --- /dev/null +++ b/src/components/utils/index.ts @@ -0,0 +1,3 @@ +export { ResendButton } from './ResendButton' +export { useConfirmDialog, confirmDialog } from './ConfirmDialog' + diff --git a/src/features/daily-report/chart-config.ts b/src/features/daily-report/chart-config.ts new file mode 100644 index 0000000..e6fbaf4 --- /dev/null +++ b/src/features/daily-report/chart-config.ts @@ -0,0 +1,54 @@ +import { ChartConfig } from './types' +import { NormalizedTelemetry } from './types' + +export const BASE_CHART_CONFIGS: ChartConfig[] = [ + { + key: 'soil', + title: 'رطوبت خاک', + seriesLabel: 'رطوبت خاک (%)', + color: '#16a34a', + bgColor: '#dcfce7', + getValue: (t: NormalizedTelemetry) => t.soil, + yAxisMin: 0, + yAxisMax: 100, + }, + { + key: 'hum', + title: 'رطوبت', + seriesLabel: 'رطوبت (%)', + color: '#3b82f6', + bgColor: '#dbeafe', + getValue: (t: NormalizedTelemetry) => t.hum, + yAxisMin: 0, + yAxisMax: 100, + }, + { + key: 'temp', + title: 'دما', + seriesLabel: 'دما (°C)', + color: '#ef4444', + bgColor: '#fee2e2', + getValue: (t: NormalizedTelemetry) => t.temp, + yAxisMin: 0, + }, + { + key: 'lux', + title: 'نور', + seriesLabel: 'Lux', + color: '#a855f7', + bgColor: '#f3e8ff', + 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 new file mode 100644 index 0000000..a4eb717 --- /dev/null +++ b/src/features/daily-report/hooks/useTelemetryCharts.ts @@ -0,0 +1,58 @@ +import { useMemo } from 'react' +import { NormalizedTelemetry, ChartConfig, ChartData } from '../types' +import { DataGap } from '../utils' +import { insertGapsInData, calcMinMax } from '../utils' +import { BASE_CHART_CONFIGS } from '../chart-config' + +type UseTelemetryChartsParams = { + filteredTelemetry: NormalizedTelemetry[] + filteredDataGaps: DataGap[] +} + +export function useTelemetryCharts({ + filteredTelemetry, + filteredDataGaps, +}: UseTelemetryChartsParams) { + const timestamps = useMemo( + () => filteredTelemetry.map(t => t.timestamp), + [filteredTelemetry] + ) + + const chartLabels = useMemo( + () => filteredTelemetry.map(t => t.label), + [filteredTelemetry] + ) + + const charts = useMemo(() => { + return BASE_CHART_CONFIGS.map((cfg): ChartData => { + const raw = filteredTelemetry.map(cfg.getValue) + const data = insertGapsInData(raw, timestamps, filteredDataGaps) + + let yAxisMin = cfg.yAxisMin + let yAxisMax = cfg.yAxisMax + + if (cfg.key === 'temp') { + const mm = calcMinMax(data, 0, 40, 10) + yAxisMin = mm.min + yAxisMax = mm.max + } else if (cfg.key === 'lux') { + const mm = calcMinMax(data, 0, 2000, 1000) + yAxisMin = mm.min + yAxisMax = mm.max + } + + return { + ...cfg, + data, + yAxisMin, + yAxisMax: yAxisMax ?? yAxisMin, + } + }) + }, [filteredTelemetry, timestamps, filteredDataGaps]) + + return { + charts, + chartLabels, + } +} + diff --git a/src/features/daily-report/index.ts b/src/features/daily-report/index.ts new file mode 100644 index 0000000..4525a11 --- /dev/null +++ b/src/features/daily-report/index.ts @@ -0,0 +1,5 @@ +// Daily report feature exports +export * from './types' +export * from './utils' +export * from './chart-config' + diff --git a/src/features/daily-report/types.ts b/src/features/daily-report/types.ts new file mode 100644 index 0000000..ae4bc50 --- /dev/null +++ b/src/features/daily-report/types.ts @@ -0,0 +1,37 @@ +export type TabType = 'summary' | 'charts' | 'weather' | 'analysis' + +export const TABS: { value: TabType; label: string }[] = [ + { value: 'summary', label: 'خلاصه' }, + { value: 'charts', label: 'نمودار' }, + { value: 'weather', label: 'آب و هوا' }, + { value: 'analysis', label: 'تحلیل' }, +] + +export type NormalizedTelemetry = { + minute: number + timestamp: string + label: string + soil: number + temp: number + hum: number + gas: number + lux: number +} + +export type ChartConfig = { + key: string + title: string + seriesLabel: string + color: string + bgColor: string + getValue: (t: NormalizedTelemetry) => number + yAxisMin: number + yAxisMax?: number +} + +export type ChartData = ChartConfig & { + data: (number | null)[] + yAxisMin: number + yAxisMax: number +} + diff --git a/src/components/daily-report/utils.ts b/src/features/daily-report/utils.ts similarity index 71% rename from src/components/daily-report/utils.ts rename to src/features/daily-report/utils.ts index 90caaee..a61e1bf 100644 --- a/src/components/daily-report/utils.ts +++ b/src/features/daily-report/utils.ts @@ -1,25 +1,6 @@ import { Cloud, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-react' - -// Format date to yyyy/MM/dd -export function formatPersianDate(year: number, month: number, day: number): string { - const mm = month.toString().padStart(2, '0') - const dd = day.toString().padStart(2, '0') - return `${year}/${mm}/${dd}` -} - -// Ensure date string is in yyyy/MM/dd format -export function ensureDateFormat(dateStr: string): string { - const parts = dateStr.split('/') - if (parts.length !== 3) return dateStr - const [year, month, day] = parts.map(Number) - return formatPersianDate(year, month, day) -} - -// تابع تبدیل ارقام انگلیسی به فارسی -export function toPersianDigits(num: number | string): string { - const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] - return num.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)]) -} +import { TelemetryDto } from '@/lib/api' +import { NormalizedTelemetry } from './types' // Weather code to description and icon mapping export const weatherCodeMap: Record }> = { @@ -169,3 +150,76 @@ export function fillGapsWithNull( return result } +// Insert gaps in data (alternative implementation used in charts) +export function 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]) + + if (i < data.length - 1) { + const cur = new Date(timestamps[i]) + const next = new Date(timestamps[i + 1]) + + const curMin = cur.getHours() * 60 + cur.getMinutes() + const nextMin = next.getHours() * 60 + next.getMinutes() + + const hasGap = gaps.some( + g => curMin <= g.startMinute && nextMin >= g.endMinute + ) + + if (hasGap) result.push(null) + } + } + + return result +} + +// Calculate min/max for chart axes with step rounding +export function calcMinMax( + data: (number | null)[], + defaultMin: number, + defaultMax: number, + step: number +) { + const valid = data.filter((v): v is number => v !== null) + if (!valid.length) return { min: defaultMin, max: defaultMax } + + const min = Math.min(...valid) + const max = Math.max(...valid) + + return { + min: min < defaultMin ? Math.floor(min / step) * step : defaultMin, + max: max > defaultMax ? Math.ceil(max / step) * step : defaultMax, + } +} + +// Normalize telemetry data +export function normalizeTelemetryData(telemetry: TelemetryDto[]): NormalizedTelemetry[] { + return telemetry.map(t => { + const ts = t.serverTimestampUtc || t.timestampUtc + const d = new Date(ts) + + const h = d.getHours() + const m = d.getMinutes() + const s = d.getSeconds() + + return { + timestamp: ts, + minute: h * 60 + m, + label: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`, + soil: Number(t.soilPercent ?? 0), + temp: Number(t.temperatureC ?? 0), + hum: Number(t.humidityPercent ?? 0), + gas: Number(t.gasPPM ?? 0), + lux: Number(t.lux ?? 0), + } + }) +} + diff --git a/src/features/weather/api.ts b/src/features/weather/api.ts new file mode 100644 index 0000000..1759f2b --- /dev/null +++ b/src/features/weather/api.ts @@ -0,0 +1,159 @@ +"use client" + +import { WeatherData } from './types' +import { persianToGregorian } from '@/lib/date/persian-date' +import { QOM_LAT, QOM_LON } from './helpers' + +/** + * Fetch location name from coordinates using reverse geocoding + */ +export async function fetchLocationName(latitude: number, longitude: number): Promise { + try { + const response = await fetch( + `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=10&addressdetails=1&accept-language=fa,en` + ) + + if (!response.ok) { + throw new Error('Failed to fetch location name') + } + + const data = await response.json() + + // Extract location name from address + const address = data.address || {} + + // Try to get the most specific location name + const locationParts: string[] = [] + + if (address.city || address.town || address.village) { + locationParts.push(address.city || address.town || address.village) + } + + if (address.state || address.province) { + locationParts.push(address.state || address.province) + } + + if (address.country) { + locationParts.push(address.country) + } + + // If we have parts, join them, otherwise use display_name + if (locationParts.length > 0) { + return locationParts.join('، ') + } + + // Fallback to display_name + return data.display_name || 'موقعیت نامشخص' + } catch (error) { + console.error('Error fetching location name:', error) + // Fallback to default location name + return 'کهک قم، ایران' + } +} + +/** + * Fetch historical weather data for past dates + */ +export async function fetchHistoricalWeather(selectedDate: string): Promise { + // تبدیل تاریخ شمسی به میلادی + const [year, month, day] = selectedDate.split('/').map(Number) + const gregorianDate = persianToGregorian(year, month, day) + 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) + return { + 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, + }] + } +} + +/** + * Fetch forecast weather data for today and future dates + */ +export async function fetchForecastWeather(): Promise { + const response = await fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${QOM_LAT}&longitude=${QOM_LON}¤t=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], + })) + + return { + 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], + })) + } +} + +/** + * Check if a Persian date is today + */ +export function isToday(selectedDate: string): boolean { + try { + 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) + + return gregorianDate.getTime() === today.getTime() + } catch (e) { + console.error('Error checking if today:', e) + return false + } +} + diff --git a/src/components/daily-report/weather-helpers.ts b/src/features/weather/helpers.ts similarity index 96% rename from src/components/daily-report/weather-helpers.ts rename to src/features/weather/helpers.ts index 1978938..aee9fe7 100644 --- a/src/components/daily-report/weather-helpers.ts +++ b/src/features/weather/helpers.ts @@ -1,10 +1,10 @@ import { Thermometer, Sun, Droplets, Wind, Leaf } from 'lucide-react' import { WeatherData, GreenhouseAlert } from './types' -import { toPersianDigits } from './utils' +import { toPersianDigits } from '@/lib/format/persian-digits' -// Qom coordinates -export const QOM_LAT = 34.6416 -export const QOM_LON = 50.8746 +// Kahak Qom coordinates +export const QOM_LAT = 34.39674800 +export const QOM_LON = 50.86594800 // Greenhouse-specific recommendations export function getGreenhouseAlerts(weather: WeatherData): GreenhouseAlert[] { diff --git a/src/features/weather/index.ts b/src/features/weather/index.ts new file mode 100644 index 0000000..0ed6072 --- /dev/null +++ b/src/features/weather/index.ts @@ -0,0 +1,5 @@ +// Weather feature exports +export * from './types' +export * from './api' +export * from './helpers' + diff --git a/src/components/daily-report/types.ts b/src/features/weather/types.ts similarity index 70% rename from src/components/daily-report/types.ts rename to src/features/weather/types.ts index fc2984c..cce69f4 100644 --- a/src/components/daily-report/types.ts +++ b/src/features/weather/types.ts @@ -1,5 +1,3 @@ -export type TabType = 'summary' | 'charts' | 'weather' | 'analysis' - export type WeatherData = { current: { temperature: number @@ -34,10 +32,3 @@ export type GreenhouseAlert = { icon: React.ComponentType<{ className?: string }> } -export const TABS: { value: TabType; label: string }[] = [ - { value: 'summary', label: 'خلاصه' }, - { value: 'charts', label: 'نمودار' }, - { value: 'weather', label: 'آب و هوا' }, - { value: 'analysis', label: 'تحلیل' }, -] - diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..9089050 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' + +/** + * Hook برای debounce کردن یک مقدار + * @param value - مقداری که باید debounce بشه + * @param delay - تاخیر به میلی‌ثانیه (پیش‌فرض: 300ms) + * @returns مقدار debounced شده + */ +export function useDebounce(value: T, delay: number = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(handler) + } + }, [value, delay]) + + return debouncedValue +} + diff --git a/src/hooks/usePullToRefresh.ts b/src/hooks/usePullToRefresh.ts new file mode 100644 index 0000000..2000004 --- /dev/null +++ b/src/hooks/usePullToRefresh.ts @@ -0,0 +1,73 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' + +export function usePullToRefresh(onRefresh: () => Promise) { + const touchStartY = useRef(0) + const isRefreshing = useRef(false) + const [pullDistance, setPullDistance] = useState(0) + + useEffect(() => { + const handleTouchStart = (e: TouchEvent) => { + if (window.scrollY === 0) { + touchStartY.current = e.touches[0].clientY + } + } + + const handleTouchMove = (e: TouchEvent) => { + if (window.scrollY === 0 && touchStartY.current > 0) { + const touchY = e.touches[0].clientY + const distance = touchY - touchStartY.current + + if (distance > 0 && !isRefreshing.current) { + // Limit pull distance to 150px + const limitedDistance = Math.min(distance, 150) + setPullDistance(limitedDistance) + + // Prevent default scrolling when pulling down + if (distance > 10) { + e.preventDefault() + } + } + } + } + + const handleTouchEnd = async (e: TouchEvent) => { + if (window.scrollY === 0 && touchStartY.current > 0) { + const touchY = e.changedTouches[0].clientY + const distance = touchY - touchStartY.current + + if (distance > 100 && !isRefreshing.current) { + isRefreshing.current = true + setPullDistance(0) + try { + await onRefresh() + } finally { + isRefreshing.current = false + } + } else { + setPullDistance(0) + } + } + touchStartY.current = 0 + } + + // Only enable on touch devices + if ('ontouchstart' in window) { + window.addEventListener('touchstart', handleTouchStart, { passive: false }) + window.addEventListener('touchmove', handleTouchMove, { passive: false }) + window.addEventListener('touchend', handleTouchEnd) + } + + return () => { + if ('ontouchstart' in window) { + window.removeEventListener('touchstart', handleTouchStart) + window.removeEventListener('touchmove', handleTouchMove) + window.removeEventListener('touchend', handleTouchEnd) + } + } + }, [onRefresh]) + + return { pullDistance, isRefreshing: isRefreshing.current } +} + diff --git a/src/lib/api.ts b/src/lib/api/client.ts similarity index 61% rename from src/lib/api.ts rename to src/lib/api/client.ts index 869b538..01496e2 100644 --- a/src/lib/api.ts +++ b/src/lib/api/client.ts @@ -1,150 +1,22 @@ "use client" -export type DeviceDto = { - id: number - deviceName: string - userId: number - userName: string - userFamily: string - userMobile: string - location: string - neshanLocation: string -} - -export type TelemetryDto = { - id: number - deviceId: number - timestampUtc: string - temperatureC: number - humidityPercent: number - soilPercent: number - gasPPM: number - lux: number - persianYear: number - persianMonth: number - persianDate: string - deviceName?: string - serverTimestampUtc?: string -} - -export type DeviceSettingsDto = { - id: number - deviceId: number - deviceName: string - dangerMaxTemperature: number - dangerMinTemperature: number - maxTemperature: number - minTemperature: number - maxGasPPM: number - minGasPPM: number - maxLux: number - minLux: number - maxHumidityPercent: number - minHumidityPercent: number - createdAt: string - updatedAt: string -} - -export type SendCodeRequest = { - mobile: string -} - -export type SendCodeResponse = { - success: boolean - message?: string - resendAfterSeconds: number -} - -export type VerifyCodeRequest = { - mobile: string - code: string -} - -export type VerifyCodeResponse = { - success: boolean - message?: string - token?: string - user?: { - id: number - mobile: string - name: string - family: string - } -} - -export type PagedResult = { - items: T[] - totalCount: number - page: number - pageSize: number -} - -export type DailyReportDto = { - id: number - deviceId: number - deviceName: string - persianDate: string - analysis: string - recordCount: number - sampledRecordCount: number - totalTokens: number - createdAt: string - fromCache: boolean -} - -export type AlertRuleDto = { - id: number - alertConditionId: number - sensorType: 0 | 1 | 2 | 3 | 4 // Temperature=0, Humidity=1, Soil=2, Gas=3, Lux=4 - comparisonType: 0 | 1 | 2 | 3 // GreaterThan=0, LessThan=1, Between=2, OutOfRange=3 - value1: number - value2?: number // برای Between و OutOfRange - order: number -} - -export type CreateAlertRuleRequest = { - sensorType: 0 | 1 | 2 | 3 | 4 - comparisonType: 0 | 1 | 2 | 3 - value1: number - value2?: number - order: number -} - -export type AlertConditionDto = { - id: number - deviceId: number - deviceName: string - notificationType: 0 | 1 // Call=0, SMS=1 - timeType: 0 | 1 | 2 // Day=0, Night=1, Always=2 - callCooldownMinutes: number - smsCooldownMinutes: number - isEnabled: boolean - rules: AlertRuleDto[] - createdAt: string - updatedAt: string -} - -export type CreateAlertConditionDto = { - deviceId: number - notificationType: 0 | 1 - timeType: 0 | 1 | 2 - callCooldownMinutes?: number - smsCooldownMinutes?: number - isEnabled: boolean - rules: CreateAlertRuleRequest[] -} - -export type UpdateAlertConditionDto = { - id: number - notificationType: 0 | 1 - timeType: 0 | 1 | 2 - callCooldownMinutes?: number - smsCooldownMinutes?: number - isEnabled: boolean - rules: CreateAlertRuleRequest[] -} +import type { + DeviceDto, + TelemetryDto, + DeviceSettingsDto, + SendCodeRequest, + SendCodeResponse, + VerifyCodeRequest, + VerifyCodeResponse, + PagedResult, + DailyReportDto, + AlertConditionDto, + CreateAlertConditionDto, + UpdateAlertConditionDto, +} from './types' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://ghback.nabaksoft.ir' + async function http(url: string, init?: RequestInit): Promise { const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } }) if (!res.ok) { @@ -224,3 +96,4 @@ export const api = { deleteAlertCondition: (id: number) => http(`${API_BASE}/api/alertconditions/${id}`, { method: 'DELETE' }) } + diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 0000000..9b10979 --- /dev/null +++ b/src/lib/api/index.ts @@ -0,0 +1,4 @@ +// Re-export API client and types for backward compatibility +export { api } from './client' +export * from './types' + diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts new file mode 100644 index 0000000..7af66a9 --- /dev/null +++ b/src/lib/api/types.ts @@ -0,0 +1,146 @@ +// API DTOs and Types + +export type DeviceDto = { + id: number + deviceName: string + userId: number + userName: string + userFamily: string + userMobile: string + location: string + neshanLocation: string +} + +export type TelemetryDto = { + id: number + deviceId: number + timestampUtc: string + temperatureC: number + humidityPercent: number + soilPercent: number + gasPPM: number + lux: number + persianYear: number + persianMonth: number + persianDate: string + deviceName?: string + serverTimestampUtc?: string +} + +export type DeviceSettingsDto = { + id: number + deviceId: number + deviceName: string + dangerMaxTemperature: number + dangerMinTemperature: number + maxTemperature: number + minTemperature: number + maxGasPPM: number + minGasPPM: number + maxLux: number + minLux: number + maxHumidityPercent: number + minHumidityPercent: number + createdAt: string + updatedAt: string +} + +export type SendCodeRequest = { + mobile: string +} + +export type SendCodeResponse = { + success: boolean + message?: string + resendAfterSeconds: number +} + +export type VerifyCodeRequest = { + mobile: string + code: string +} + +export type VerifyCodeResponse = { + success: boolean + message?: string + token?: string + user?: { + id: number + mobile: string + name: string + family: string + } +} + +export type PagedResult = { + items: T[] + totalCount: number + page: number + pageSize: number +} + +export type DailyReportDto = { + id: number + deviceId: number + deviceName: string + persianDate: string + analysis: string + recordCount: number + sampledRecordCount: number + totalTokens: number + createdAt: string + fromCache: boolean +} + +export type AlertRuleDto = { + id: number + alertConditionId: number + sensorType: 0 | 1 | 2 | 3 | 4 // Temperature=0, Humidity=1, Soil=2, Gas=3, Lux=4 + comparisonType: 0 | 1 | 2 | 3 // GreaterThan=0, LessThan=1, Between=2, OutOfRange=3 + value1: number + value2?: number // برای Between و OutOfRange + order: number +} + +export type CreateAlertRuleRequest = { + sensorType: 0 | 1 | 2 | 3 | 4 + comparisonType: 0 | 1 | 2 | 3 + value1: number + value2?: number + order: number +} + +export type AlertConditionDto = { + id: number + deviceId: number + deviceName: string + notificationType: 0 | 1 // Call=0, SMS=1 + timeType: 0 | 1 | 2 // Day=0, Night=1, Always=2 + callCooldownMinutes: number + smsCooldownMinutes: number + isEnabled: boolean + rules: AlertRuleDto[] + createdAt: string + updatedAt: string +} + +export type CreateAlertConditionDto = { + deviceId: number + notificationType: 0 | 1 + timeType: 0 | 1 | 2 + callCooldownMinutes?: number + smsCooldownMinutes?: number + isEnabled: boolean + rules: CreateAlertRuleRequest[] +} + +export type UpdateAlertConditionDto = { + id: number + notificationType: 0 | 1 + timeType: 0 | 1 | 2 + callCooldownMinutes?: number + smsCooldownMinutes?: number + isEnabled: boolean + rules: CreateAlertRuleRequest[] +} + diff --git a/src/lib/persian-date.ts b/src/lib/date/persian-date.ts similarity index 99% rename from src/lib/persian-date.ts rename to src/lib/date/persian-date.ts index 0707ccb..a366f3b 100644 --- a/src/lib/persian-date.ts +++ b/src/lib/date/persian-date.ts @@ -123,4 +123,5 @@ export function getNextPersianDay(dateStr: string): string | null { const nextPersian = gregorianToPersian(gregorian) return formatPersianDateString(nextPersian) -} \ No newline at end of file +} + diff --git a/src/lib/format/index.ts b/src/lib/format/index.ts new file mode 100644 index 0000000..08d8e97 --- /dev/null +++ b/src/lib/format/index.ts @@ -0,0 +1,4 @@ +// Re-export formatting utilities +export * from './persian-digits' +export * from './persian-date' + diff --git a/src/lib/format/persian-date.ts b/src/lib/format/persian-date.ts new file mode 100644 index 0000000..23f94da --- /dev/null +++ b/src/lib/format/persian-date.ts @@ -0,0 +1,23 @@ +/** + * Persian date formatting utilities + */ + +/** + * Format date to yyyy/MM/dd + */ +export function formatPersianDate(year: number, month: number, day: number): string { + const mm = month.toString().padStart(2, '0') + const dd = day.toString().padStart(2, '0') + return `${year}/${mm}/${dd}` +} + +/** + * Ensure date string is in yyyy/MM/dd format + */ +export function ensureDateFormat(dateStr: string): string { + const parts = dateStr.split('/') + if (parts.length !== 3) return dateStr + const [year, month, day] = parts.map(Number) + return formatPersianDate(year, month, day) +} + diff --git a/src/lib/format/persian-digits.ts b/src/lib/format/persian-digits.ts new file mode 100644 index 0000000..7f86461 --- /dev/null +++ b/src/lib/format/persian-digits.ts @@ -0,0 +1,12 @@ +/** + * Persian digit formatting utilities + */ + +/** + * تابع تبدیل ارقام انگلیسی به فارسی + */ +export function toPersianDigits(num: number | string): string { + const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] + return num.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)]) +} + diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..00acf67 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,7 @@ +/** + * Utility function to merge class names + */ +export function cn(...classes: (string | undefined | null | false)[]): string { + return classes.filter(Boolean).join(' ') +} + diff --git a/src/lib/utils/sun-utils.ts b/src/lib/utils/sun-utils.ts new file mode 100644 index 0000000..049e01b --- /dev/null +++ b/src/lib/utils/sun-utils.ts @@ -0,0 +1,51 @@ +/** + * محاسبه زمان طلوع و غروب خورشید برای یک موقعیت جغرافیایی + * @param latitude عرض جغرافیایی (درجه) + * @param longitude طول جغرافیایی (درجه) + * @param timezoneOffset افست زمانی از UTC (ساعت) + * @returns اطلاعات طلوع و غروب خورشید + */ +export function calculateSunTimes( + latitude: number = 34.39674800, // کهک قم + longitude: number = 50.86594800, + timezoneOffset: number = 3.5 // ایران +) { + const now = new Date() + const dayOfYear = Math.floor((now.getTime() - new Date(now.getFullYear(), 0, 0).getTime()) / 86400000) + + // محاسبه انحراف خورشید (Solar Declination) + const declination = -23.44 * Math.cos((2 * Math.PI / 365) * (dayOfYear + 10)) + + // محاسبه زاویه ساعتی طلوع (Hour Angle) + const latRad = latitude * Math.PI / 180 + const decRad = declination * Math.PI / 180 + const cosHourAngle = -Math.tan(latRad) * Math.tan(decRad) + + // در صورتی که خورشید طلوع/غروب می‌کند + if (Math.abs(cosHourAngle) <= 1) { + const hourAngle = Math.acos(cosHourAngle) * 180 / Math.PI + + // زمان طلوع و غروب به ساعت محلی (با دقیقه دقیق) + const sunriseDecimal = 12 - hourAngle / 15 + (longitude / 15 - timezoneOffset) + const sunsetDecimal = 12 + hourAngle / 15 + (longitude / 15 - timezoneOffset) + + // تبدیل به ساعت و دقیقه + const sunriseHour = Math.floor(sunriseDecimal) + const sunriseMinute = Math.round((sunriseDecimal - sunriseHour) * 60) + + const sunsetHour = Math.floor(sunsetDecimal) + const sunsetMinute = Math.round((sunsetDecimal - sunsetHour) * 60) + + return { + sunrise: { hour: sunriseHour, minute: sunriseMinute, decimal: sunriseDecimal }, + sunset: { hour: sunsetHour, minute: sunsetMinute, decimal: sunsetDecimal } + } + } + + // مقادیر پیش‌فرض (برای روزهای قطبی) + return { + sunrise: { hour: 6, minute: 0, decimal: 6 }, + sunset: { hour: 18, minute: 0, decimal: 18 } + } +} + diff --git a/src/lib/utils/time-utils.ts b/src/lib/utils/time-utils.ts new file mode 100644 index 0000000..3649bef --- /dev/null +++ b/src/lib/utils/time-utils.ts @@ -0,0 +1,57 @@ +import { toPersianDigits } from '@/lib/format/persian-digits' + +/** + * تبدیل دقیقه از نیمه شب به ساعت و دقیقه + * @param minute دقیقه از نیمه شب (0-1439) + * @returns object شامل hour و minute + */ +export function minuteToHours(minute: number): { hour: number; minute: number } { + return { + hour: Math.floor(minute / 60), + minute: minute % 60 + } +} + +/** + * تبدیل دقیقه به درصد موقعیت در timeline (0-100) + * برای RTL layout: 0 ساعت در راست (100%)، 24 ساعت در چپ (0%) + * @param minute دقیقه از نیمه شب (0-1439) + * @param maxMinutes حداکثر دقیقه (پیش‌فرض: 1439) + * @returns درصد موقعیت (0-100) + */ +export function minuteToPercent(minute: number, maxMinutes: number = 1439): number { + return ((maxMinutes - minute) / maxMinutes) * 100 +} + +/** + * فرمت کردن زمان برای نمایش به صورت HH:MM با ارقام فارسی + * @param hour ساعت + * @param minute دقیقه + * @returns رشته فرمت شده با ارقام فارسی + */ +export function formatTimeLabel(hour: number, minute: number): string { + return `${toPersianDigits(hour.toString().padStart(2, '0'))}:${toPersianDigits(minute.toString().padStart(2, '0'))}` +} + +/** + * محاسبه مدت زمان بازه انتخاب شده به ساعت و دقیقه + * @param startMinute دقیقه شروع + * @param endMinute دقیقه پایان + * @returns object شامل hour و minute + */ +export function calculateDuration(startMinute: number, endMinute: number): { hour: number; minute: number } { + const durationMinutes = endMinute - startMinute + 1 + return minuteToHours(durationMinutes) +} + +/** + * فرمت کردن زمان برای نمایش با prefix (مثل "طلوع" یا "غروب") + * @param prefix پیشوند (مثل "طلوع" یا "غروب") + * @param hour ساعت + * @param minute دقیقه + * @returns رشته فرمت شده با prefix و ارقام فارسی + */ +export function formatSunTimeLabel(prefix: string, hour: number, minute: number): string { + return `${prefix} ${formatTimeLabel(hour, minute)}` +} +