-
-
- {canResend ? (
- 'ارسال مجدد کد'
- ) : (
- `ارسال مجدد (${Math.floor(resendCooldown / 60)}:${String(resendCooldown % 60).padStart(2, '0')})`
- )}
-
+
+ )
+}
+
+// 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 (
+
+
+
+
+
+
+ شما {toPersianDigits(alertsCount)} هشدار آب و هوایی برای روزهای آینده دارید
+
+
+ برای مشاهده جزئیات و توصیههای مدیریت گلخانه کلیک کنید
+
+
+
+
+
+
+ )
+}
+
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 (
+
+
+
+ انتخاب سال:
+
+ onYearChange(Number(e.target.value))}
+ >
+ {years.map((year) => (
+
+ {year}
+
+ ))}
+
+
+ )
+}
+
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 (
+
+ {day}
+
+
+ {recordCount}
+
+
+ )
+ }
+
+ return (
+
+ )
+}
+
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 (
+
+
+ {name}
+
+ {isActive && stats ? (
+
+
+
+ {stats.days} روز
+
+
+ {stats.records} رکورد
+
+
+ ) : (
+ بدون داده
+ )}
+ {isActive && (
+
+ )}
+
+ )
+}
+
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 (
+
+ {buttonContent}
+
+ {tooltip}
+
+
+ )
+ }
+
+ return (
+
+ {buttonContent}
+
+ )
+}
+
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 => (
+ setActiveTab(tab.value)}
+ className={`flex-1 px-2 py-2.5 text-xs font-medium rounded-lg transition-all duration-200 ${
+ activeTab === tab.value
+ ? 'bg-white text-indigo-600 shadow-sm'
+ : 'text-gray-600 hover:text-gray-900'
+ }`}
+ >
+ {tab.label}
+
+ ))}
+
+ )
+}
\ 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 => (
+ setActiveTab(tab.value)}
+ className={`flex-1 px-6 py-4 text-sm font-medium transition-all duration-200 whitespace-nowrap ${
+ activeTab === tab.value
+ ? 'text-indigo-600 border-b-2 border-indigo-600 bg-indigo-50/50'
+ : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
+ }`}
+ >
+ {tab.label}
+
+ ))}
+
+
+
+ {/* 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)} گپ در دادهها
-
- )}
-
-
{
- onStartMinuteChange(0)
- onEndMinuteChange(1439) // 23:59
- }}
- className="px-3 py-1.5 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
- >
- کل روز
-
-
+
{/* 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 (
-
-
onDayToggle(isExpanded ? null : index)}
- className={`w-full flex items-center justify-between p-4 transition-all duration-200 ${
- isExpanded
- ? 'bg-emerald-500 text-white'
- : isToday
- ? 'bg-emerald-50 hover:bg-emerald-100'
- : hasFrost
- ? 'bg-blue-50 hover:bg-blue-100'
- : hasHeat
- ? 'bg-red-50 hover:bg-red-100'
- : 'bg-gray-50 hover:bg-gray-100'
- }`}
- >
-
-
-
-
-
-
- {isToday ? 'امروز' : getPersianDayName(day.date)}
-
-
- {weatherInfo.description}
-
-
-
-
-
-
- {toPersianDigits(Math.round(day.tempMax))}°
-
-
- {toPersianDigits(Math.round(day.tempMin))}°
-
-
-
-
-
-
- {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 (
+
+ )
+}
+
+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 (
+
+
onDayToggle(isExpanded ? null : index)}
+ className={`w-full flex items-center justify-between p-4 transition-all duration-200 ${
+ isExpanded
+ ? 'bg-emerald-500 text-white'
+ : isToday
+ ? 'bg-emerald-50 hover:bg-emerald-100'
+ : hasFrost
+ ? 'bg-blue-50 hover:bg-blue-100'
+ : hasHeat
+ ? 'bg-red-50 hover:bg-red-100'
+ : 'bg-gray-50 hover:bg-gray-100'
+ }`}
+ >
+
+
+
+
+
+
+ {isToday ? 'امروز' : getPersianDayName(day.date)}
+
+
+ {weatherInfo.description}
+
+
+
+
+
+
+ {toPersianDigits(Math.round(day.tempMax))}°
+
+
+ {toPersianDigits(Math.round(day.tempMin))}°
+
+
+
+
+
+
+ {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 && (
+
+ {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 (
+
+ )
+}
+
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 (
+
+
+
+ {selectedDate ? toPersianDigits(selectedDate) : 'انتخاب تاریخ'}
+
+
+
+ )
+}
+
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 (
+
+
onPageChange(Math.max(1, currentPage - 1))}
+ disabled={currentPage === 1}
+ className={cn(
+ 'px-4 py-2 rounded-xl transition-all duration-200 flex items-center gap-2',
+ currentPage === 1
+ ? 'bg-gray-100 text-gray-400 cursor-not-allowed'
+ : 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
+ )}
+ >
+
+ قبلی
+
+
+
+ {visiblePages.map((pageNum) => (
+ onPageChange(pageNum)}
+ className={cn(
+ 'w-10 h-10 rounded-xl transition-all duration-200',
+ currentPage === pageNum
+ ? 'bg-gradient-to-r from-green-500 to-green-600 text-white shadow-md'
+ : 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
+ )}
+ >
+ {pageNum}
+
+ ))}
+
+
+
onPageChange(Math.min(totalPages, currentPage + 1))}
+ disabled={currentPage === totalPages}
+ className={cn(
+ 'px-4 py-2 rounded-xl transition-all duration-200 flex items-center gap-2',
+ currentPage === totalPages
+ ? 'bg-gray-100 text-gray-400 cursor-not-allowed'
+ : 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
+ )}
+ >
+ بعدی
+
+
+
+ )
+}
+
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 (
+
+
{label}
+
+ 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 (
+
+ )
+}
+
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}
+
+
+ {options.cancelText || 'انصراف'}
+
+
+ {options.confirmText || 'تأیید'}
+
+
+
+
+ )
+
+ 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 (
+
+
+ {canResend ? (
+ 'ارسال مجدد کد'
+ ) : (
+ `ارسال مجدد (${formatCooldown(cooldown)})`
+ )}
+
+ )
+}
+
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)}`
+}
+