From ab72c019993eaac84d414ce42267d17feaea3069 Mon Sep 17 00:00:00 2001 From: ferdzo Date: Sat, 29 Nov 2025 00:05:41 +0100 Subject: [PATCH] Added Calendar viewer and Environment overview with AI, Health overviewer --- frontend/src/App.tsx | 23 +- frontend/src/api/index.ts | 147 +++++ frontend/src/components/AddWidgetModal.tsx | 449 ++++++++++++-- frontend/src/components/EditWidgetModal.tsx | 389 ++++++++++-- .../components/widgets/AiInsightWidget.tsx | 325 +++++++++- .../src/components/widgets/CalendarWidget.tsx | 308 ++++++++++ .../components/widgets/ComfortIndexWidget.tsx | 68 ++- .../widgets/DailyBriefingWidget.tsx | 266 ++++++++ .../components/widgets/HealthStatsWidget.tsx | 178 ++++++ .../components/widgets/LineChartWidget.tsx | 157 +++-- .../widgets/RunSuitabilityWidget.tsx | 162 +++++ .../src/components/widgets/WeatherWidget.tsx | 144 ++++- .../components/widgets/WidgetContainer.tsx | 58 +- frontend/src/components/widgets/index.ts | 1 + frontend/src/components/widgets/registry.ts | 8 + .../src/components/widgets/widget-styles.css | 37 +- frontend/src/hooks/useDashboardConfig.ts | 124 +++- frontend/src/hooks/useGridstack.ts | 155 +++++ frontend/src/hooks/useTelemetrySeries.ts | 3 +- frontend/src/hooks/useWellnessState.tsx | 40 ++ frontend/src/lib/api-client.ts | 16 +- frontend/src/pages/Dashboard.tsx | 167 +++-- infrastructure/.gitignore | 4 +- iotDashboard/api/serializers.py | 11 + iotDashboard/api/urls.py | 8 +- iotDashboard/api/views.py | 575 +++++++++++++++++- iotDashboard/dashboard_models.py | 65 ++ iotDashboard/gpt_service_client.py | 69 +++ iotDashboard/health_insights.py | 354 +++++++++++ ...sortype_remove_device_humidity_and_more.py | 75 +-- .../0003_create_dashboard_layouts.py | 82 +++ iotDashboard/run_suitability.py | 333 ++++++++++ iotDashboard/settings.py | 18 + 33 files changed, 4436 insertions(+), 383 deletions(-) create mode 100644 frontend/src/components/widgets/CalendarWidget.tsx create mode 100644 frontend/src/components/widgets/DailyBriefingWidget.tsx create mode 100644 frontend/src/components/widgets/HealthStatsWidget.tsx create mode 100644 frontend/src/components/widgets/RunSuitabilityWidget.tsx create mode 100644 frontend/src/hooks/useGridstack.ts create mode 100644 frontend/src/hooks/useWellnessState.tsx create mode 100644 iotDashboard/dashboard_models.py create mode 100644 iotDashboard/health_insights.py create mode 100644 iotDashboard/migrations/0003_create_dashboard_layouts.py create mode 100644 iotDashboard/run_suitability.py diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 203449e..e4825ae 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter, Routes, Route, Link, NavLink } from 'react-router-dom' import { Toaster } from 'react-hot-toast' +import { WellnessStateProvider } from './hooks/useWellnessState' import Dashboard from './pages/Dashboard' import DeviceList from './pages/DeviceList' import DeviceDetail from './pages/DeviceDetail' @@ -43,7 +44,7 @@ function AppLayout({ children }: { children: React.ReactNode }) { - IoT Dashboard + Lyncis @@ -81,15 +82,17 @@ function AppLayout({ children }: { children: React.ReactNode }) { function App() { return ( - - - - } /> - } /> - } /> - } /> - - + + + + + } /> + } /> + } /> + } /> + + + ) } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 910b1ba..f19970c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -146,3 +146,150 @@ export const weatherApi = { sensor_count: number; }>('/weather/air_quality/', { params: { city } }), }; + +// Dashboard Layout API +export const dashboardLayoutApi = { + getAll: () => + apiClient.get>('/dashboard-layouts/'), + + getDefault: () => + apiClient.get<{ + id: number + name: string + config: any + is_default: boolean + created_at: string + updated_at: string + }>('/dashboard-layouts/default/'), + + create: (data: { + name: string + config: any + is_default?: boolean + }) => + apiClient.post('/dashboard-layouts/', data), + + update: (id: number, data: { + name?: string + config?: any + is_default?: boolean + }) => + apiClient.put(`/dashboard-layouts/${id}/`, data), + + delete: (id: number) => + apiClient.delete(`/dashboard-layouts/${id}/`), + + setDefault: (id: number) => + apiClient.post(`/dashboard-layouts/${id}/set_default/`), +} + +// Wellness API +export const wellnessApi = { + getRunSuitability: (healthDeviceId: string, city: string, timeOfDay?: string) => + apiClient.get<{ + status: 'GO' | 'MODERATE' | 'NO'; + overall_score: number; + scores: { + weather: number; + air_quality: number; + health: number; + }; + primary_reason: string; + detailed_insights: string[]; + time_recommendations: string[]; + suggestions: string[]; + weather_data: { + temperature: number; + wind_speed: number; + precipitation: number; + description: string; + }; + air_quality_data: { + pm25: number | null; + pm10: number | null; + status: string; + }; + health_data: { + steps: number; + active_calories: number; + heart_rate: number | null; + }; + }>('/wellness/run_suitability/', { + params: { + health_device_id: healthDeviceId, + city, + ...(timeOfDay && { time_of_day: timeOfDay }), + }, + }), + + getHealthInsights: (healthDeviceId: string, city?: string) => + apiClient.get<{ + health_metrics: { + steps: number; + active_calories: number; + heart_rate: number | null; + resting_heart_rate: number | null; + }; + environmental_context: { + temperature: number | null; + humidity: number | null; + pm25: number | null; + pm10: number | null; + air_quality_status: string | null; + weather_description: string | null; + }; + insights: Array<{ + metric: string; + value: number; + context: string; + correlation: string | null; + recommendation: string | null; + }>; + correlations: string[]; + recommendations: string[]; + trend_indicators: string[]; + }>('/wellness/health_insights/', { + params: { + health_device_id: healthDeviceId, + ...(city && { city }), + }, + }), + + getDailyBriefing: (params: { + briefing_type: 'schedule' | 'environment' | 'full'; + city: string; + health_device_id?: string; + calendar_url?: string; + calendar_range_hours?: number; + }) => + apiClient.post<{ + status_emoji: string; + status_line: string; + insights: string[]; + recommendations: string[]; + briefing_type: string; + generated_at: string; + context: { + indoor: Record | null; + outdoor: Record | null; + health: Record | null; + calendar_event_count: number; + }; + }>('/wellness/daily_briefing/', params), +}; + +// Calendar API +export const calendarApi = { + fetch: (calendarUrl: string, options?: { signal?: AbortSignal }) => + apiClient.get<{ calendar_data: string }>('/calendar/fetch/', { + params: { calendar_url: calendarUrl }, + signal: options?.signal, + }), +}; diff --git a/frontend/src/components/AddWidgetModal.tsx b/frontend/src/components/AddWidgetModal.tsx index 8b9cd43..68c9fbd 100644 --- a/frontend/src/components/AddWidgetModal.tsx +++ b/frontend/src/components/AddWidgetModal.tsx @@ -18,8 +18,12 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa const [selectedMetrics, setSelectedMetrics] = useState([]) const [timeframeHours, setTimeframeHours] = useState(24) const [widgetWidth, setWidgetWidth] = useState(1) - const [widgetHeight, setWidgetHeight] = useState(2) + const [widgetHeight, setWidgetHeight] = useState(3) const [city, setCity] = useState('Skopje') + const [calendarUrl, setCalendarUrl] = useState('') + const [showCalendarUrl, setShowCalendarUrl] = useState(false) + const [calendarRangeHours, setCalendarRangeHours] = useState(72) + const [briefingType, setBriefingType] = useState<'schedule' | 'environment' | 'full'>('full') // Fetch devices const { data: devicesData } = useQuery({ @@ -44,6 +48,10 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa const devices = devicesData?.results || [] const availableMetrics = deviceMetricsData || [] + // Validation helpers + const needsDevice = !['weather', 'air-quality', 'calendar', 'daily-briefing'].includes(widgetType) + const needsMetrics = !['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widgetType) + // Reset form when modal opens useEffect(() => { if (isOpen) { @@ -54,7 +62,11 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa setSelectedMetrics([]) setTimeframeHours(24) setWidgetWidth(1) - setWidgetHeight(2) + setWidgetHeight(3) + setCalendarUrl('') + setShowCalendarUrl(false) + setCalendarRangeHours(72) + setBriefingType('full') } }, [isOpen]) @@ -64,12 +76,31 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa }, [selectedDevices]) const handleSubmit = () => { - // Weather and air-quality widgets don't need device/metric validation - if (widgetType !== 'weather' && widgetType !== 'air-quality') { - if (selectedDevices.length === 0 || selectedMetrics.length === 0) { - alert('Please select at least one device and one metric') - return - } + // Validation + if (needsDevice && selectedDevices.length === 0) { + alert('Please select a device') + return + } + + if (needsMetrics && selectedMetrics.length === 0) { + alert('Please select at least one metric') + return + } + + const wellnessWidgets = ['run-suitability', 'health-stats'] + if (wellnessWidgets.includes(widgetType) && !city) { + alert('Please enter a city for wellness widgets') + return + } + + if (widgetType === 'calendar' && !calendarUrl.trim()) { + alert('Please provide an iCal URL') + return + } + + if (widgetType === 'daily-briefing' && !city) { + alert('Please enter a city for daily briefing') + return } // Create title @@ -78,6 +109,16 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa defaultTitle = `Weather - ${city}` } else if (widgetType === 'air-quality') { defaultTitle = `Air Quality - ${city}` + } else if (widgetType === 'run-suitability') { + defaultTitle = `Run Suitability - ${city}` + } else if (widgetType === 'health-stats') { + const selectedDevice = devices.find(d => d.id === selectedDevices[0]) + defaultTitle = `Health StaTts - ${selectedDevice?.name || 'Device'}` + } else if (widgetType === 'calendar') { + defaultTitle = 'Calendar Agenda' + } else if (widgetType === 'daily-briefing') { + const briefingLabels = { schedule: 'Schedule', environment: 'Environment', full: 'Full' } + defaultTitle = `${briefingLabels[briefingType]} Briefing` } else { const selectedDevice = devices.find(d => d.id === selectedDevices[0]) defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics) @@ -87,8 +128,8 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa id: `widget-${Date.now()}`, type: widgetType, title: title || defaultTitle, - deviceIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedDevices, - metricIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedMetrics, + deviceIds: needsDevice ? selectedDevices : [], + metricIds: needsMetrics ? selectedMetrics : [], timeframe: { hours: timeframeHours, }, @@ -96,13 +137,24 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa showLegend: true, showGrid: true, height: widgetType === 'line-chart' ? 300 : undefined, - city: widgetType === 'weather' || widgetType === 'air-quality' ? city : undefined, + city: (widgetType === 'weather' || widgetType === 'air-quality' || wellnessWidgets.includes(widgetType) || widgetType === 'daily-briefing') ? city : undefined, }, + calendar: widgetType === 'calendar' ? { + icalUrl: calendarUrl.trim(), + timeRangeHours: calendarRangeHours, + } : undefined, + briefing: widgetType === 'daily-briefing' ? { + briefingType, + city, + healthDeviceId: selectedDevices[0], + calendarUrl: calendarUrl.trim() || undefined, + calendarRangeHours: calendarRangeHours, + } : undefined, position: { x: 0, y: 0, w: widgetWidth, - h: widgetHeight, + h: ['run-suitability', 'health-stats', 'daily-briefing'].includes(widgetType) ? 2 : widgetHeight, }, } @@ -249,6 +301,58 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
Multi-factor
+ + + + + + + + @@ -260,7 +364,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa className="btn btn-primary" onClick={() => { // Skip data source step for weather and air quality widgets - if (widgetType === 'weather' || widgetType === 'air-quality') { + if (widgetType === 'weather' || widgetType === 'air-quality' || widgetType === 'calendar' || widgetType === 'daily-briefing') { setStep(3) } else { setStep(2) @@ -274,8 +378,29 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa )} {/* Step 2: Data Source */} - {step === 2 && widgetType !== 'weather' && widgetType !== 'air-quality' && ( + {step === 2 && widgetType !== 'weather' && widgetType !== 'air-quality' && widgetType !== 'calendar' && widgetType !== 'daily-briefing' && (
+ {/* City input for wellness widgets */} + {['run-suitability', 'health-stats'].includes(widgetType) && ( +
+ + setCity(e.target.value)} + /> + +
+ )} +
-
- - {selectedDevices.length === 0 ? ( -
- Please select a device first -
- ) : ( - <> - {(['stat', 'gauge'].includes(widgetType)) && ( -
- - - - This widget type supports only one metric -
- )} -
- {availableMetrics.length === 0 ? ( -
- No metrics found for this device -
- ) : ( - availableMetrics.map((metric) => ( - - )) - )} + + + {/* Metric Selection - Skip for widgets with hardcoded metrics */} + {!['run-suitability', 'health-stats'].includes(widgetType) && ( +
+ + {selectedDevices.length === 0 ? ( +
+ Please select a device first
- - )} -
+ ) : ( + <> + {(['stat', 'gauge'].includes(widgetType)) && ( +
+ + + + This widget type supports only one metric +
+ )} +
+ {availableMetrics.length === 0 ? ( +
+ No metrics found for this device +
+ ) : ( + availableMetrics.map((metric) => ( + + )) + )} +
+ + )} +
+ )}
@@ -402,6 +536,194 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa />
+ ) : widgetType === 'calendar' ? ( + <> +
+ +
+ setCalendarUrl(e.target.value)} + /> + +
+ +
+ +
+ + +
+ +
+ + setTitle(e.target.value)} + /> +
+ + ) : widgetType === 'daily-briefing' ? ( + <> +
+ +
+ + + +
+ +
+ +
+ + setCity(e.target.value)} + /> +
+ + {(briefingType === 'schedule' || briefingType === 'full') && ( + <> +
+ +
+ setCalendarUrl(e.target.value)} + /> + +
+ +
+ + {calendarUrl && ( +
+ + +
+ )} + + {devices.length > 0 && ( +
+ + + +
+ )} + + )} + +
+ + setTitle(e.target.value)} + /> +
+ ) : ( // Original configuration for sensor-based widgets <> @@ -478,14 +800,23 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
Widget Summary
Type: {widgetType}
-
Device: {devices.find(d => d.id === selectedDevices[0])?.name}
-
Metrics: {selectedMetrics.join(', ')}
+
Device: {needsDevice ? (devices.find(d => d.id === selectedDevices[0])?.name || 'Not selected') : 'Not required'}
+
Metrics: {needsMetrics ? (selectedMetrics.join(', ') || 'Not selected') : 'Not required'}
Size: {widgetWidth} × {widgetHeight}
+ {widgetType === 'calendar' && ( +
Range: Next {calendarRangeHours}h
+ )} + {widgetType === 'daily-briefing' && ( +
Briefing: {briefingType.charAt(0).toUpperCase() + briefingType.slice(1)}
+ )}
-
+ {/* Device Selection */} + {!['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type) && ( +
+ + +
+ )} + {/* Title */}
{/* Metrics */} -
- - {(['stat', 'gauge'].includes(widget.type)) && ( -
- - - - This widget type supports only one metric -
- )} -
- {availableMetrics.length === 0 ? ( -
- Loading metrics... + {!['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type) && ( +
+ + {(['stat', 'gauge'].includes(widget.type)) && ( +
+ + + + This widget type supports only one metric
- ) : ( - availableMetrics.map((metric) => ( - - )) )} +
+ {!selectedDeviceId ? ( +
+ Please select a device first +
+ ) : availableMetrics.length === 0 ? ( +
+ Loading metrics... +
+ ) : ( + availableMetrics.map((metric) => ( + + )) + )} +
-
+ )} {/* Time Range */} -
- - -
+ {!['calendar', 'daily-briefing'].includes(widget.type) && ( +
+ + +
+ )} + + {widget.type === 'calendar' && ( + <> +
+ +
+ setCalendarUrl(e.target.value)} + /> + +
+
+ +
+ + +
+ + )} + + {widget.type === 'daily-briefing' && ( + <> +
+ +
+ + + +
+
+ +
+ + setCity(e.target.value)} + /> +
+ + {(briefingType === 'schedule' || briefingType === 'full') && ( + <> +
+ +
+ setCalendarUrl(e.target.value)} + /> + +
+
+ + {calendarUrl && ( +
+ + +
+ )} + + {devices.length > 0 && ( +
+ + +
+ )} + + )} + + )} {/* Size */}
diff --git a/frontend/src/components/widgets/AiInsightWidget.tsx b/frontend/src/components/widgets/AiInsightWidget.tsx index 31b9837..bf28216 100644 --- a/frontend/src/components/widgets/AiInsightWidget.tsx +++ b/frontend/src/components/widgets/AiInsightWidget.tsx @@ -7,6 +7,38 @@ interface AiInsightWidgetProps { config: WidgetConfig } +interface TrendSummary { + status: 'excellent' | 'good' | 'fair' | 'poor' + summary: string + trends: Array<{ + metric: string + direction: 'improving' | 'stable' | 'degrading' + description: string + }> + comfort_score: { + rating: number + description: string + } + patterns: string[] + recommendations: string[] + forecast: string +} + +interface AnomalyDetection { + status: 'normal' | 'warning' | 'critical' + summary: string + anomalies: Array<{ + metric: string + severity: 'low' | 'medium' | 'high' | 'critical' + description: string + value: string + expected: string + }> + impacts: string[] + actions: string[] + root_causes: string[] +} + export default function AiInsightWidget({ config }: AiInsightWidgetProps) { const { deviceIds, metricIds, timeframe, title } = config const [promptType, setPromptType] = useState<'trend_summary' | 'anomaly_detection'>('trend_summary') @@ -40,6 +72,52 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) { refetch() } + // Parse JSON analysis if it's a string + const parsedAnalysis = analysis?.analysis ? (() => { + try { + return typeof analysis.analysis === 'string' + ? JSON.parse(analysis.analysis) + : analysis.analysis + } catch { + return null // If parsing fails, return null to show raw text + } + })() : null + + const isTrendSummary = promptType === 'trend_summary' && parsedAnalysis + const isAnomalyDetection = promptType === 'anomaly_detection' && parsedAnalysis + + const getStatusColor = (status: string) => { + switch (status) { + case 'excellent': return 'badge-success' + case 'good': return 'badge-info' + case 'fair': return 'badge-warning' + case 'poor': return 'badge-error' + case 'normal': return 'badge-success' + case 'warning': return 'badge-warning' + case 'critical': return 'badge-error' + default: return 'badge-ghost' + } + } + + const getDirectionIcon = (direction: string) => { + switch (direction) { + case 'improving': return '↑' + case 'degrading': return '↓' + case 'stable': return '→' + default: return '•' + } + } + + const getSeverityColor = (severity: string) => { + switch (severity) { + case 'critical': return 'badge-error' + case 'high': return 'badge-warning' + case 'medium': return 'badge-warning badge-outline' + case 'low': return 'badge-info' + default: return 'badge-ghost' + } + } + return (
@@ -99,20 +177,239 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) { {analysis && showAnalysis && !isLoading && (
-
-
- {promptType === 'trend_summary' ? 'Trend Analysis' : 'Anomaly Detection'} -
-
- {analysis.data_points_analyzed} data points analyzed -
-
-
-
-
- {analysis.analysis} -
-
+ {/* Structured Display for Trend Summary */} + {isTrendSummary && parsedAnalysis && ( + <> +
+
+ {parsedAnalysis.status.toUpperCase()} +
+
+ {analysis.data_points_analyzed} data points +
+
+ + {/* Summary */} +
+ + + +
+
Summary
+
{parsedAnalysis.summary}
+
+
+ + {/* Comfort Score */} + {parsedAnalysis.comfort_score && ( +
+
+
+
+ {parsedAnalysis.comfort_score.rating} +
+
+
Comfort Score
+
{parsedAnalysis.comfort_score.description}
+
+
+
+
+ )} + + {/* Trends */} + {parsedAnalysis.trends && parsedAnalysis.trends.length > 0 && ( +
+

Trends

+
+ {parsedAnalysis.trends.map((trend: TrendSummary['trends'][0], i: number) => ( +
+
+
+ {getDirectionIcon(trend.direction)} +
+
{trend.metric}
+
{trend.description}
+
+
{trend.direction}
+
+
+
+ ))} +
+
+ )} + + {/* Patterns */} + {parsedAnalysis.patterns && parsedAnalysis.patterns.length > 0 && ( +
+

Patterns Detected

+
    + {parsedAnalysis.patterns.map((pattern: string, i: number) => ( +
  • + + {pattern} +
  • + ))} +
+
+ )} + + {/* Recommendations */} + {parsedAnalysis.recommendations && parsedAnalysis.recommendations.length > 0 && ( +
+

Recommendations

+
+ {parsedAnalysis.recommendations.map((rec: string, i: number) => ( +
+ + + + {rec} +
+ ))} +
+
+ )} + + {/* Forecast */} + {parsedAnalysis.forecast && ( +
+ + + +
+
Forecast
+
{parsedAnalysis.forecast}
+
+
+ )} + + )} + + {/* Structured Display for Anomaly Detection */} + {isAnomalyDetection && parsedAnalysis && ( + <> +
+
+ {parsedAnalysis.status.toUpperCase()} +
+
+ {analysis.data_points_analyzed} data points +
+
+ + {/* Summary */} +
+ + + +
+
Summary
+
{parsedAnalysis.summary}
+
+
+ + {/* Anomalies */} + {parsedAnalysis.anomalies && parsedAnalysis.anomalies.length > 0 && ( +
+

Anomalies Detected

+
+ {parsedAnalysis.anomalies.map((anomaly: AnomalyDetection['anomalies'][0], i: number) => ( +
+
+
+
+
+ {anomaly.metric} + + {anomaly.severity} + +
+
{anomaly.description}
+
+ Current: {anomaly.value} + {' • '} + Expected: {anomaly.expected} +
+
+
+
+
+ ))} +
+
+ )} + + {/* Impacts */} + {parsedAnalysis.impacts && parsedAnalysis.impacts.length > 0 && ( +
+

Potential Impacts

+
    + {parsedAnalysis.impacts.map((impact: string, i: number) => ( +
  • + + {impact} +
  • + ))} +
+
+ )} + + {/* Actions */} + {parsedAnalysis.actions && parsedAnalysis.actions.length > 0 && ( +
+

Recommended Actions

+
+ {parsedAnalysis.actions.map((action: string, i: number) => ( +
+ + + + {action} +
+ ))} +
+
+ )} + + {/* Root Causes */} + {parsedAnalysis.root_causes && parsedAnalysis.root_causes.length > 0 && ( +
+

Possible Root Causes

+
    + {parsedAnalysis.root_causes.map((cause: string, i: number) => ( +
  • + + {cause} +
  • + ))} +
+
+ )} + + )} + + {/* Fallback: Raw Text Display */} + {!parsedAnalysis && ( + <> +
+
+ {promptType === 'trend_summary' ? 'Trend Analysis' : 'Anomaly Detection'} +
+
+ {analysis.data_points_analyzed} data points analyzed +
+
+
+
+
+ {analysis.analysis} +
+
+ + )} +
+ {lastUpdated && ( + + Updated {timeFormatter.format(lastUpdated)} + + )} +
+
+ + {error && ( +
+ {error} +
+ )} + + {!error && events.length === 0 && !isLoading && ( +
+ No upcoming events in this window. +
+ )} + + {isLoading && events.length === 0 && ( +
+ +
+ )} + + {events.length > 0 && ( +
    + {events.map((event) => ( +
  • +
    +
    +

    + {event.summary || 'Untitled event'} +

    +

    + {formatEventRange(event)} +

    + {event.location && ( +

    + + + + {event.location} +

    + )} +
    +
    + {event.isAllDay ? 'All day' : timeFormatter.format(event.start)} +
    +
    +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/widgets/ComfortIndexWidget.tsx b/frontend/src/components/widgets/ComfortIndexWidget.tsx index 00f6bf0..f589b5a 100644 --- a/frontend/src/components/widgets/ComfortIndexWidget.tsx +++ b/frontend/src/components/widgets/ComfortIndexWidget.tsx @@ -1,8 +1,61 @@ +import type { ReactNode } from 'react' import { useQuery } from '@tanstack/react-query' import { devicesApi } from '../../api' import type { WidgetConfig } from '../../hooks' import './widget-styles.css' +type IconProps = { className?: string } + +const IconBase = ({ className, children }: IconProps & { children: ReactNode }) => ( + + {children} + +) + +const ThermometerIcon = ({ className }: IconProps) => ( + + + + +) + +const DropletIcon = ({ className }: IconProps) => ( + + + +) + +const AirQualityIcon = ({ className }: IconProps) => ( + + + + +) + +const AcousticIcon = ({ className }: IconProps) => ( + + + + + +) + +const LightIcon = ({ className }: IconProps) => ( + + + + + +) + interface ComfortIndexWidgetProps { config: WidgetConfig } @@ -85,7 +138,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
- 🌡️ + Temperature
@@ -95,7 +148,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
- 💧 + Humidity
@@ -105,7 +158,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
- 🌬️ + Air Quality
@@ -115,7 +168,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
- 🔊 + Acoustic
@@ -125,7 +178,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
- 💡 + Lighting
@@ -137,7 +190,10 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps) {/* Suggestions */} {data.suggestions.length > 0 && (
-
💡 Suggestions
+
+ + Suggestions +
{data.suggestions.map((suggestion, i) => (
diff --git a/frontend/src/components/widgets/DailyBriefingWidget.tsx b/frontend/src/components/widgets/DailyBriefingWidget.tsx new file mode 100644 index 0000000..7160bb4 --- /dev/null +++ b/frontend/src/components/widgets/DailyBriefingWidget.tsx @@ -0,0 +1,266 @@ +import { useState, useCallback } from 'react' +import { wellnessApi } from '../../api' +import type { WidgetConfig } from '../../hooks' +import './widget-styles.css' + +interface DailyBriefingWidgetProps { + config: WidgetConfig +} + +interface BriefingData { + status_emoji: string + status_line: string + insights: string[] + recommendations: string[] + briefing_type: string + generated_at: string + context: { + indoor: Record | null + outdoor: Record | null + health: Record | null + calendar_event_count: number + } +} + +const CalendarIcon = () => ( + + + +) + +const BuildingIcon = () => ( + + + +) + +const ClipboardIcon = () => ( + + + +) + +const LightbulbIcon = () => ( + + + +) + +const StatusIcon = ({ status }: { status: string }) => { + if (status.includes('good') || status.includes('great') || status.includes('optimal')) { + return ( + + + + ) + } + if (status.includes('warning') || status.includes('moderate') || status.includes('attention')) { + return ( + + + + ) + } + return ( + + + + ) +} + +const BRIEFING_TYPES = [ + { value: 'schedule', label: 'Schedule', Icon: CalendarIcon, description: 'Calendar & activity focus' }, + { value: 'environment', label: 'Environment', Icon: BuildingIcon, description: 'Workspace conditions' }, + { value: 'full', label: 'Full', Icon: ClipboardIcon, description: 'Complete overview' }, +] as const + +type BriefingType = typeof BRIEFING_TYPES[number]['value'] + +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit', +}) + +export default function DailyBriefingWidget({ config }: DailyBriefingWidgetProps) { + const briefingConfig = config.briefing + const [briefingType, setBriefingType] = useState( + (briefingConfig?.briefingType as BriefingType) || 'full' + ) + const [briefingData, setBriefingData] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const city = config.visualization?.city || briefingConfig?.city || 'Skopje' + const healthDeviceId = config.deviceIds?.[0] || briefingConfig?.healthDeviceId + const calendarUrl = briefingConfig?.calendarUrl + + const generateBriefing = useCallback(async () => { + setIsLoading(true) + setError(null) + + try { + const response = await wellnessApi.getDailyBriefing({ + briefing_type: briefingType, + city, + health_device_id: healthDeviceId, + calendar_url: calendarUrl, + calendar_range_hours: briefingConfig?.calendarRangeHours || 24, + }) + + setBriefingData(response.data) + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to generate briefing' + setError(message) + } finally { + setIsLoading(false) + } + }, [briefingType, city, healthDeviceId, calendarUrl, briefingConfig?.calendarRangeHours]) + + const getBriefingTypeInfo = (type: BriefingType) => { + return BRIEFING_TYPES.find(t => t.value === type) || BRIEFING_TYPES[2] + } + + const CurrentIcon = getBriefingTypeInfo(briefingType).Icon + + // No config state - show setup message + if (!city) { + return ( +
+
+

{config.title || 'Daily Briefing'}

+

+ Configure a city to generate briefings. +

+
+
+ ) + } + + return ( +
+
+ {/* Header */} +
+
+

+ {getBriefingTypeInfo(briefingType).label} Briefing +

+

{config.title || 'Daily Briefing'}

+
+
+ + {/* Briefing Type Selector */} +
+ {BRIEFING_TYPES.map((type) => ( + + ))} +
+ + {/* Generate Button or Content */} + {!briefingData && !isLoading && ( +
+

+ Get AI-powered insights for your day +

+ +
+ )} + + {/* Loading State */} + {isLoading && ( +
+ +

Analyzing your environment...

+
+ )} + + {/* Error State */} + {error && !isLoading && ( +
+
+ {error} +
+ +
+ )} + + {/* Briefing Content */} + {briefingData && !isLoading && ( +
+ {/* Status Line */} +
+
+ +

{briefingData.status_line}

+
+
+ + {/* Insights */} + {briefingData.insights.length > 0 && ( +
+ {briefingData.insights.map((insight, i) => ( +
+ + {insight} +
+ ))} +
+ )} + + {/* Recommendations */} + {briefingData.recommendations.length > 0 && ( +
+
+ +
+ {briefingData.recommendations.map((rec, i) => ( +

{rec}

+ ))} +
+
+
+ )} + + {/* Footer with timestamp and refresh */} +
+ + Generated {timeFormatter.format(new Date(briefingData.generated_at))} + + +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/widgets/HealthStatsWidget.tsx b/frontend/src/components/widgets/HealthStatsWidget.tsx new file mode 100644 index 0000000..dbb7f39 --- /dev/null +++ b/frontend/src/components/widgets/HealthStatsWidget.tsx @@ -0,0 +1,178 @@ +import { useQuery } from '@tanstack/react-query' +import { wellnessApi } from '../../api' +import { useWellnessState } from '../../hooks/useWellnessState' +import type { WidgetConfig } from '../../hooks' +import './widget-styles.css' + +interface HealthStatsWidgetProps { + config: WidgetConfig +} + +export default function HealthStatsWidget({ config }: HealthStatsWidgetProps) { + const { healthDeviceId, city } = useWellnessState() + + // Use device from config or shared state + const deviceId = config.deviceIds[0] || healthDeviceId + const widgetCity = (config.visualization as Record)?.city as string || city + + const { data, isLoading, error } = useQuery({ + queryKey: ['health-insights', deviceId, widgetCity], + queryFn: async () => { + if (!deviceId) { + throw new Error('Device ID is required') + } + const response = await wellnessApi.getHealthInsights(deviceId, widgetCity || undefined) + return response.data + }, + refetchInterval: 60000, // Refresh every minute + enabled: !!deviceId, + }) + + if (isLoading) { + return ( +
+
+ +
+
+ ) + } + + if (error) { + return ( +
+
+
+ + {error instanceof Error ? error.message : 'Failed to load health data'} + +
+ {!deviceId && ( +

Select a health device

+ )} +
+
+ ) + } + + if (!data) return null + + const StepIcon = () => ( + + + + ) + + const FireIcon = () => ( + + + + ) + + const HeartIcon = () => ( + + + + ) + + const CheckIcon = () => ( + + + + ) + + const WarningIcon = () => ( + + + + ) + + const stats = [ + { + label: 'Steps', + value: data.health_metrics.steps, + unit: '', + icon: , + color: 'text-primary' + }, + { + label: 'Calories', + value: data.health_metrics.active_calories, + unit: 'kcal', + icon: , + color: 'text-secondary' + }, + { + label: 'Heart Rate', + value: data.health_metrics.heart_rate, + unit: 'bpm', + icon: , + color: 'text-error' + }, + ] + + // Find insights for each stat + const getInsightForMetric = (metric: string) => { + return data.insights.find(i => i.metric === metric) + } + + return ( +
+
+

{config.title}

+ + {/* Health Stats */} +
+ {stats.map((stat, i) => { + const insight = getInsightForMetric(stat.label) + const hasValue = stat.value !== null && stat.value !== undefined + + return ( +
+
+
+ {stat.icon} + {stat.label} +
+
+ + {hasValue ? Math.round(Number(stat.value)).toLocaleString() : '—'} + + {hasValue && {stat.unit}} +
+
+ + {/* Context/Correlation */} + {insight && ( +
+ {insight.context && ( +
{insight.context}
+ )} + {insight.correlation && ( +
+ + {insight.correlation} +
+ )} +
+ )} +
+ ) + })} +
+ + {/* Recommendations */} + {data.recommendations.length > 0 && ( +
+
+
+ + {data.recommendations[0]} +
+
+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/widgets/LineChartWidget.tsx b/frontend/src/components/widgets/LineChartWidget.tsx index 564a57b..092d6c7 100644 --- a/frontend/src/components/widgets/LineChartWidget.tsx +++ b/frontend/src/components/widgets/LineChartWidget.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, memo } from 'react' import { LineChart, Line, @@ -17,15 +17,39 @@ interface LineChartWidgetProps { config: WidgetConfig } +// Optimized date formatter - cache formatters to avoid recreating +const timeFormatter = new Intl.DateTimeFormat('en-US', { + hour: '2-digit', + minute: '2-digit', +}) + +const dateTimeFormatter = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', +}) + +// Helper to format date efficiently +function formatTime(date: Date): string { + return timeFormatter.format(date) +} + +function formatDateTime(date: Date): string { + return dateTimeFormatter.format(date) +} + // Helper component to fetch data for a single metric function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: WidgetConfig['timeframe']) { - // Fetch data for each metric (React allows hooks in arrays when count is stable) + // Only fetch data for metrics that exist (up to 5) const metric1 = useTelemetrySeries({ deviceId, metric: metricIds[0] || '', hours: timeframe.hours, startTime: timeframe.startTime, endTime: timeframe.endTime, + limit: 500, // Limit data points for performance }) const metric2 = useTelemetrySeries({ @@ -34,6 +58,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi hours: timeframe.hours, startTime: timeframe.startTime, endTime: timeframe.endTime, + limit: 500, + enabled: metricIds.length > 1, }) const metric3 = useTelemetrySeries({ @@ -42,6 +68,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi hours: timeframe.hours, startTime: timeframe.startTime, endTime: timeframe.endTime, + limit: 500, + enabled: metricIds.length > 2, }) const metric4 = useTelemetrySeries({ @@ -50,6 +78,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi hours: timeframe.hours, startTime: timeframe.startTime, endTime: timeframe.endTime, + limit: 500, + enabled: metricIds.length > 3, }) const metric5 = useTelemetrySeries({ @@ -58,6 +88,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi hours: timeframe.hours, startTime: timeframe.startTime, endTime: timeframe.endTime, + limit: 500, + enabled: metricIds.length > 4, }) const queries = [metric1, metric2, metric3, metric4, metric5].slice(0, metricIds.length) @@ -65,7 +97,7 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi return { queries, metricIds } } -export default function LineChartWidget({ config }: LineChartWidgetProps) { +function LineChartWidget({ config }: LineChartWidgetProps) { const { deviceIds, metricIds, timeframe, visualization } = config const deviceId = deviceIds[0] @@ -74,64 +106,83 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) { const isLoading = queries.some((q) => q.isLoading) const error = queries.find((q) => q.error)?.error - // Combine data from all metrics into a single chart dataset + // Combine data from all metrics into a single chart dataset (optimized) const chartData = useMemo(() => { if (queries.length === 0 || !queries[0]?.data || queries[0].data.length === 0) return [] - // Create a map of time -> { time, fullDateTime, metric1, metric2, ... } - const timeMap = new Map>() + // Limit total data points for performance (max 300 points) + const MAX_POINTS = 300 + const totalPoints = queries.reduce((sum, q) => sum + (q.data?.length || 0), 0) + const shouldDownsample = totalPoints > MAX_POINTS + const step = shouldDownsample ? Math.ceil(totalPoints / MAX_POINTS) : 1 + + // Create a map of timestamp -> data point (using timestamp as key for better performance) + const timeMap = new Map>() queries.forEach((query, index) => { const metric = metricIds[index] - if (!query.data) return + if (!query.data || query.data.length === 0) return - query.data.forEach((point) => { - const date = new Date(point.time) + // Process data points efficiently (with downsampling if needed) + query.data.forEach((point, pointIndex) => { + // Skip points if downsampling + if (shouldDownsample && pointIndex % step !== 0) return - // Short time for X-axis display (just time) - const timeStr = date.toLocaleString('en-US', { - hour: '2-digit', - minute: '2-digit', - }) + const timestamp = new Date(point.time).getTime() - // Full date/time for tooltip - const fullDateTime = date.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) - - const key = date.getTime().toString() - - if (!timeMap.has(key)) { - timeMap.set(key, { - time: timeStr, - fullDateTime: fullDateTime, - timestamp: date.getTime() + if (!timeMap.has(timestamp)) { + const date = new Date(timestamp) + timeMap.set(timestamp, { + time: formatTime(date), + fullDateTime: formatDateTime(date), + timestamp }) } - const entry = timeMap.get(key)! + const entry = timeMap.get(timestamp)! entry[metric] = point.value }) }) - // Sort by timestamp - return Array.from(timeMap.values()).sort((a, b) => { + // Sort by timestamp and convert to array + const result = Array.from(timeMap.values()).sort((a, b) => { return (a.timestamp as number) - (b.timestamp as number) }) + + return result }, [queries, metricIds]) - const colors = visualization?.colors || [ - '#3b82f6', // blue - '#10b981', // green - '#f59e0b', // amber - '#ef4444', // red - '#8b5cf6', // purple - '#ec4899', // pink - ] + // Memoize colors to avoid recreating array + const colors = useMemo(() => + visualization?.colors || [ + '#3b82f6', // blue + '#10b981', // green + '#f59e0b', // amber + '#ef4444', // red + '#8b5cf6', // purple + '#ec4899', // pink + ], + [visualization?.colors] + ) + + // Memoize lines to avoid recreating on every render + const lines = useMemo(() => + metricIds.map((metric, index) => ( + + )), + [metricIds, colors] + ) if (isLoading) { return ( @@ -190,7 +241,11 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) { {config.title || metricIds.map(formatMetricName).join(' & ')} - + {visualization?.showGrid !== false && ( )} @@ -200,6 +255,7 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) { angle={-45} textAnchor="end" height={50} + interval="preserveStartEnd" // Reduce number of ticks /> [value.toFixed(2)]} /> {visualization?.showLegend !== false && } - {metricIds.map((metric, index) => ( - - ))} + {lines}
) } + +// Memoize the component to prevent unnecessary re-renders +export default memo(LineChartWidget) diff --git a/frontend/src/components/widgets/RunSuitabilityWidget.tsx b/frontend/src/components/widgets/RunSuitabilityWidget.tsx new file mode 100644 index 0000000..44942e1 --- /dev/null +++ b/frontend/src/components/widgets/RunSuitabilityWidget.tsx @@ -0,0 +1,162 @@ +import { useQuery } from '@tanstack/react-query' +import { wellnessApi } from '../../api' +import { useWellnessState } from '../../hooks/useWellnessState' +import type { WidgetConfig } from '../../hooks' +import './widget-styles.css' + +const CheckIcon = () => ( + + + +) + +const StepIcon = () => ( + + + +) + +interface RunSuitabilityWidgetProps { + config: WidgetConfig +} + +export default function RunSuitabilityWidget({ config }: RunSuitabilityWidgetProps) { + const { healthDeviceId, city } = useWellnessState() + + // Use device from config or shared state + const deviceId = config.deviceIds[0] || healthDeviceId + const widgetCity = (config.visualization as Record)?.city as string || city + + const { data, isLoading, error } = useQuery({ + queryKey: ['run-suitability', deviceId, widgetCity], + queryFn: async () => { + if (!deviceId || !widgetCity) { + throw new Error('Device ID and city are required') + } + const response = await wellnessApi.getRunSuitability(deviceId, widgetCity) + return response.data + }, + refetchInterval: 300000, // Refresh every 5 minutes + enabled: !!deviceId && !!widgetCity, + }) + + if (isLoading) { + return ( +
+
+ +
+
+ ) + } + + if (error || !data) { + return ( +
+
+
+ + {error instanceof Error ? error.message : 'No data available'} + +
+ {(!deviceId || !widgetCity) && ( +

+ {!deviceId && 'Select a health device'} + {!deviceId && !widgetCity && ' and '} + {!widgetCity && 'Select a city'} +

+ )} +
+
+ ) + } + + const getStatusColor = (status: string) => { + switch (status) { + case 'GO': return 'text-success' + case 'MODERATE': return 'text-warning' + case 'NO': return 'text-error' + default: return 'text-base-content' + } + } + + const getBgColor = (status: string) => { + switch (status) { + case 'GO': return 'bg-success/10 border-success/20' + case 'MODERATE': return 'bg-warning/10 border-warning/20' + case 'NO': return 'bg-error/10 border-error/20' + default: return 'bg-base-200' + } + } + + return ( +
+
+

{config.title}

+ + {/* Status Badge */} +
+
+ + {data.status} + +
+ + {/* Primary Reason */} +
+

{data.primary_reason}

+ + {/* Score Breakdown */} +
+ Weather: {data.scores.weather.toFixed(0)} + + Air: {data.scores.air_quality.toFixed(0)} + + Health: {data.scores.health.toFixed(0)} +
+
+
+ + {/* Detailed Insights */} + {data.detailed_insights.length > 0 && ( +
+
Conditions:
+
    + {data.detailed_insights.slice(0, 2).map((insight, i) => ( +
  • • {insight}
  • + ))} +
+
+ )} + + {/* Time Recommendations */} + {data.time_recommendations.length > 0 && ( +
+
Best Time:
+
• {data.time_recommendations[0]}
+
+ )} + + {/* Suggestions */} + {data.suggestions.length > 0 && ( +
+
+
+ + {data.suggestions[0]} +
+
+
+ )} + + {/* Quick Health Stats */} +
+ + + {data.health_data.steps.toLocaleString()} steps + +
+
+
+ ) +} diff --git a/frontend/src/components/widgets/WeatherWidget.tsx b/frontend/src/components/widgets/WeatherWidget.tsx index eda97c3..948990e 100644 --- a/frontend/src/components/widgets/WeatherWidget.tsx +++ b/frontend/src/components/widgets/WeatherWidget.tsx @@ -1,7 +1,118 @@ +import type { ReactNode } from 'react' import { useQuery } from '@tanstack/react-query' import type { WidgetConfig } from '../../hooks' import { weatherApi } from '../../api' +type IconProps = { + className?: string +} + +const IconBase = ({ className, children }: IconProps & { children: ReactNode }) => ( + + {children} + +) + +const SunIcon = ({ className }: IconProps) => ( + + + + + + + + + + + +) + +const CloudIcon = ({ className }: IconProps) => ( + + + +) + +const PartlyCloudyIcon = ({ className }: IconProps) => ( + + + + +) + +const FogIcon = ({ className }: IconProps) => ( + + + + + +) + +const RainIcon = ({ className }: IconProps) => ( + + + + + + +) + +const SnowIcon = ({ className }: IconProps) => ( + + + + + + +) + +const ThunderIcon = ({ className }: IconProps) => ( + + + + +) + +const ThermometerIcon = ({ className }: IconProps) => ( + + + + +) + +const DropletIcon = ({ className }: IconProps) => ( + + + +) + +const WindIcon = ({ className }: IconProps) => ( + + + + +) + +const CloudCoverIcon = ({ className }: IconProps) => ( + + + +) + +const RainDropIcon = ({ className }: IconProps) => ( + + + +) + interface WeatherWidgetProps { config: WidgetConfig } @@ -45,19 +156,18 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) { if (!weather) return null - // Weather code to icon mapping const getWeatherIcon = (code: number) => { - if (code === 0 || code === 1) return '☀️' // Clear/Mainly clear - if (code === 2) return '⛅' // Partly cloudy - if (code === 3) return '☁️' // Overcast - if (code >= 45 && code <= 48) return '🌫️' // Fog - if (code >= 51 && code <= 55) return '🌦️' // Drizzle - if (code >= 61 && code <= 65) return '🌧️' // Rain - if (code >= 71 && code <= 77) return '🌨️' // Snow - if (code >= 80 && code <= 82) return '🌧️' // Rain showers - if (code >= 85 && code <= 86) return '🌨️' // Snow showers - if (code >= 95) return '⛈️' // Thunderstorm - return '🌡️' + if (code === 0 || code === 1) return + if (code === 2) return + if (code === 3) return + if (code >= 45 && code <= 48) return + if (code >= 51 && code <= 55) return + if (code >= 61 && code <= 65) return + if (code >= 71 && code <= 77) return + if (code >= 80 && code <= 82) return + if (code >= 85 && code <= 86) return + if (code >= 95) return + return } return ( @@ -66,7 +176,7 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {

{config.title}

{/* Weather Icon */} -
{getWeatherIcon(weather.weather_code)}
+
{getWeatherIcon(weather.weather_code)}
{/* Temperature */}
{weather.temperature.toFixed(1)}°C
@@ -82,20 +192,20 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) { {/* Additional Info */}
- 💧 + {weather.humidity}%
- 💨 + {weather.wind_speed.toFixed(1)} km/h
- ☁️ + {weather.cloud_cover}%
{weather.precipitation > 0 && (
- 🌧️ + {weather.precipitation} mm
)} diff --git a/frontend/src/components/widgets/WidgetContainer.tsx b/frontend/src/components/widgets/WidgetContainer.tsx index b140f17..5ea07a6 100644 --- a/frontend/src/components/widgets/WidgetContainer.tsx +++ b/frontend/src/components/widgets/WidgetContainer.tsx @@ -1,3 +1,4 @@ +import { memo, useEffect, useRef } from 'react' import type { WidgetConfig } from '../../hooks' import { widgetRegistry } from './registry' @@ -5,10 +6,34 @@ interface WidgetContainerProps { config: WidgetConfig onRemove?: (id: string) => void onEdit?: (id: string) => void + onHeightChange?: (height: number) => void } -export default function WidgetContainer({ config, onRemove, onEdit }: WidgetContainerProps) { +function WidgetContainer({ config, onRemove, onEdit, onHeightChange }: WidgetContainerProps) { const WidgetComponent = widgetRegistry[config.type] + const contentRef = useRef(null) + + useEffect(() => { + if (!onHeightChange || !contentRef.current) return + + const node = contentRef.current + + const emitHeight = () => { + onHeightChange(node.scrollHeight) + } + + emitHeight() + + const resizeObserver = new ResizeObserver(() => { + emitHeight() + }) + + resizeObserver.observe(node) + + return () => { + resizeObserver.disconnect() + } + }, [onHeightChange, config.id]) if (!WidgetComponent) { return ( @@ -21,10 +46,9 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont } return ( -
- {/* Drag handle and actions */} -
-
+
+
+
Drag
-
+
{onEdit && (
- {/* Actual widget */} -
+ {/* Allow overlay to float without reserving layout space */} +
) } + +export default memo(WidgetContainer) diff --git a/frontend/src/components/widgets/index.ts b/frontend/src/components/widgets/index.ts index 6a8d435..b6aa901 100644 --- a/frontend/src/components/widgets/index.ts +++ b/frontend/src/components/widgets/index.ts @@ -3,4 +3,5 @@ export { default as LineChartWidget } from './LineChartWidget' export { default as StatWidget } from './StatWidget' export { default as GaugeWidget } from './GaugeWidget' export { default as AiInsightWidget } from './AiInsightWidget' +export { default as CalendarWidget } from './CalendarWidget' export { widgetRegistry } from './registry' diff --git a/frontend/src/components/widgets/registry.ts b/frontend/src/components/widgets/registry.ts index 58dade1..a34b428 100644 --- a/frontend/src/components/widgets/registry.ts +++ b/frontend/src/components/widgets/registry.ts @@ -8,6 +8,10 @@ import AiInsightWidget from './AiInsightWidget' import AirQualityWidget from './AirQualityWidget' import WeatherWidget from './WeatherWidget' import ComfortIndexWidget from './ComfortIndexWidget' +import RunSuitabilityWidget from './RunSuitabilityWidget' +import HealthStatsWidget from './HealthStatsWidget' +import CalendarWidget from './CalendarWidget' +import DailyBriefingWidget from './DailyBriefingWidget' interface WidgetProps { config: WidgetConfig @@ -22,4 +26,8 @@ export const widgetRegistry: Record> = { 'air-quality': AirQualityWidget, 'weather': WeatherWidget, 'comfort-index': ComfortIndexWidget, + 'run-suitability': RunSuitabilityWidget, + 'health-stats': HealthStatsWidget, + 'calendar': CalendarWidget, + 'daily-briefing': DailyBriefingWidget, } diff --git a/frontend/src/components/widgets/widget-styles.css b/frontend/src/components/widgets/widget-styles.css index 660b7de..7d303bf 100644 --- a/frontend/src/components/widgets/widget-styles.css +++ b/frontend/src/components/widgets/widget-styles.css @@ -2,7 +2,7 @@ /* Reduce card body padding for widgets - default is 2rem (32px), we want less */ .widget-card .card-body { - padding: 1rem; /* 16px instead of 32px */ + padding: 0.5rem; /* 8px - can be overridden by inline p-2 class */ } /* Lighter shadow for widgets */ @@ -10,11 +10,12 @@ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); } -/* Ensure cards fill their container properly */ +/* Ensure cards fill their container properly and align to grid */ .widget-card { height: 100%; display: flex; flex-direction: column; + width: 100%; } .widget-card .card-body { @@ -23,3 +24,35 @@ flex-direction: column; min-height: 0; /* Allow flex children to shrink */ } + +/* Gridstack alignment */ +.grid-stack-item { + display: flex; + align-items: stretch; +} + +.grid-stack-item-content { + width: 100%; + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* Gridstack drag handle */ +.grid-stack-item .drag-handle { + cursor: move; + pointer-events: auto !important; + z-index: 10; +} + +/* Ensure gridstack items are interactive */ +.grid-stack-item { + pointer-events: auto; +} + +/* Gridstack resize handles */ +.grid-stack-item .ui-resizable-handle { + pointer-events: auto !important; + z-index: 20; +} diff --git a/frontend/src/hooks/useDashboardConfig.ts b/frontend/src/hooks/useDashboardConfig.ts index 5be927a..84ffdf4 100644 --- a/frontend/src/hooks/useDashboardConfig.ts +++ b/frontend/src/hooks/useDashboardConfig.ts @@ -1,6 +1,7 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' +import { dashboardLayoutApi } from '../api' -export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather' | 'comfort-index' +export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather' | 'comfort-index' | 'run-suitability' | 'health-stats' | 'calendar' | 'daily-briefing' export interface WidgetConfig { id: string @@ -20,6 +21,17 @@ export interface WidgetConfig { height?: number city?: string } + calendar?: { + icalUrl: string + timeRangeHours?: number + } + briefing?: { + briefingType: 'schedule' | 'environment' | 'full' + city: string + healthDeviceId?: string + calendarUrl?: string + calendarRangeHours?: number + } position?: { x: number y: number @@ -43,30 +55,108 @@ const DEFAULT_CONFIG: DashboardConfig = { const STORAGE_KEY = 'iot-dashboard-config' /** - * Hook to manage dashboard configuration with localStorage persistence + * Hook to manage dashboard configuration with backend sync and localStorage fallback + * Single-user system: No authentication required */ export function useDashboardConfig() { - const [config, setConfig] = useState(() => { - try { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored) { - return JSON.parse(stored) as DashboardConfig - } - } catch (error) { - console.error('Failed to load dashboard config:', error) - } - return DEFAULT_CONFIG - }) + const [config, setConfig] = useState(DEFAULT_CONFIG) + const [isLoading, setIsLoading] = useState(true) + const [layoutId, setLayoutId] = useState(null) + const isInitialLoadRef = useRef(true) - // Persist to localStorage whenever config changes + // Load config from backend or localStorage useEffect(() => { + const loadConfig = async () => { + setIsLoading(true) + try { + // Try to load from backend + try { + const response = await dashboardLayoutApi.getDefault() + const layout = response.data + setConfig(layout.config) + setLayoutId(layout.id) + } catch (error: any) { + // No default layout found, try to create one or use localStorage fallback + console.log('No default layout found, using localStorage or creating new') + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + const parsed = JSON.parse(stored) as DashboardConfig + setConfig(parsed) + // Save to backend + try { + const response = await dashboardLayoutApi.create({ + name: 'default', + config: parsed, + is_default: true, + }) + setLayoutId(response.data.id) + } catch (err) { + console.error('Failed to save to backend:', err) + } + } + } + } catch (error) { + console.error('Failed to load dashboard config:', error) + // Fallback to localStorage + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + setConfig(JSON.parse(stored) as DashboardConfig) + } + } catch (e) { + console.error('Failed to load from localStorage:', e) + } + } finally { + setIsLoading(false) + isInitialLoadRef.current = false + } + } + + loadConfig() + }, []) + + const saveConfig = async () => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(config)) } catch (error) { - console.error('Failed to save dashboard config:', error) + console.error('Failed to save to localStorage:', error) + } + + // Save to backend + try { + if (layoutId) { + // Update existing layout + await dashboardLayoutApi.update(layoutId, { + config: config, + }) + } else { + // Create new layout + const response = await dashboardLayoutApi.create({ + name: 'default', + config: config, + is_default: true, + }) + setLayoutId(response.data.id) + } + return true + } catch (error) { + console.error('Failed to save to backend:', error) + return false + } + } + + // Only save to localStorage automatically (no backend saves) + useEffect(() => { + if (!isInitialLoadRef.current) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(config)) + } catch (error) { + console.error('Failed to save to localStorage:', error) + } } }, [config]) + const addWidget = (widget: WidgetConfig) => { setConfig((prev) => ({ ...prev, @@ -124,5 +214,7 @@ export function useDashboardConfig() { resetConfig, exportConfig, importConfig, + saveConfig, + isLoading, } } diff --git a/frontend/src/hooks/useGridstack.ts b/frontend/src/hooks/useGridstack.ts new file mode 100644 index 0000000..617eee7 --- /dev/null +++ b/frontend/src/hooks/useGridstack.ts @@ -0,0 +1,155 @@ +import { useEffect, useRef, useCallback } from 'react' +import { GridStack } from 'gridstack' +import 'gridstack/dist/gridstack.min.css' + +// Define the widget type based on gridstack.js structure +export type GridStackWidget = { + id?: string | number + x?: number + y?: number + w?: number + h?: number + minW?: number + minH?: number + maxW?: number + maxH?: number + noResize?: boolean + noMove?: boolean + locked?: boolean + [key: string]: any // Allow additional properties +} + +interface UseGridstackOptions { + columns?: number + cellHeight?: number + margin?: number + minRow?: number + onLayoutChange?: (items: GridStackWidget[]) => void + disableResize?: boolean + disableDrag?: boolean +} + +export function useGridstack(options: UseGridstackOptions = {}) { + const gridRef = useRef(null) + const gridInstanceRef = useRef(null) + const { + columns = 4, + cellHeight = 150, + margin = 12, + minRow = 1, + onLayoutChange, + disableResize = false, + disableDrag = false, + } = options + + // Initialize gridstack + useEffect(() => { + if (!gridRef.current) return + + // Clean up existing instance + if (gridInstanceRef.current) { + gridInstanceRef.current.destroy(false) + } + + // Create new gridstack instance + // Gridstack will automatically detect and manage elements with data-gs-* attributes + const grid = GridStack.init( + { + column: columns, + cellHeight, + margin, + minRow, + resizable: { + handles: 'e, se, s, sw, w', + }, + disableResize, + disableDrag, + float: false, + animate: true, + acceptWidgets: false, + // Removed handle option - entire widget is draggable for better UX + }, + gridRef.current + ) + + // Handle layout change + if (onLayoutChange) { + grid.on('change', (event, items) => { + const serialized = grid.save(false) as GridStackWidget[] + onLayoutChange(serialized) + }) + } + + gridInstanceRef.current = grid + + return () => { + if (gridInstanceRef.current) { + gridInstanceRef.current.destroy(false) + gridInstanceRef.current = null + } + } + }, [columns, cellHeight, margin, minRow, disableResize, disableDrag, onLayoutChange]) + + // Convert existing elements to gridstack widgets + const makeWidgets = useCallback(() => { + if (gridInstanceRef.current && gridRef.current) { + const items = gridRef.current.querySelectorAll('.grid-stack-item:not(.ui-draggable)') + items.forEach((item) => { + gridInstanceRef.current!.makeWidget(item as HTMLElement) + }) + } + }, []) + + // Load items into grid + const loadItems = useCallback((items: GridStackWidget[]) => { + if (gridInstanceRef.current) { + gridInstanceRef.current.load(items) + } + }, []) + + // Add item to grid + const addItem = useCallback((item: GridStackWidget) => { + if (gridInstanceRef.current) { + gridInstanceRef.current.addWidget(item) + } + }, []) + + // Remove item from grid + const removeItem = useCallback((id: string) => { + if (gridInstanceRef.current) { + const el = gridInstanceRef.current.el.querySelector(`[gs-id="${id}"]`) + if (el) { + gridInstanceRef.current.removeWidget(el as HTMLElement, false) + } + } + }, []) + + // Update item + const updateItem = useCallback((id: string, updates: Partial) => { + if (gridInstanceRef.current) { + const el = gridInstanceRef.current.el.querySelector(`[gs-id="${id}"]`) + if (el) { + gridInstanceRef.current.update(el as HTMLElement, updates) + } + } + }, []) + + // Get current layout + const getLayout = useCallback((): GridStackWidget[] => { + if (gridInstanceRef.current) { + return gridInstanceRef.current.save(false) as GridStackWidget[] + } + return [] + }, []) + + return { + gridRef, + makeWidgets, + loadItems, + addItem, + removeItem, + updateItem, + getLayout, + } +} + diff --git a/frontend/src/hooks/useTelemetrySeries.ts b/frontend/src/hooks/useTelemetrySeries.ts index df938c4..d6d11eb 100644 --- a/frontend/src/hooks/useTelemetrySeries.ts +++ b/frontend/src/hooks/useTelemetrySeries.ts @@ -62,7 +62,8 @@ export function useTelemetrySeries({ return response.data as Telemetry[] }, enabled, - refetchInterval: 30000, // Refresh every 30 seconds + refetchInterval: enabled ? 60000 : false, // Refresh every 60 seconds (reduced frequency) + staleTime: 30000, // Consider data fresh for 30 seconds }) return { diff --git a/frontend/src/hooks/useWellnessState.tsx b/frontend/src/hooks/useWellnessState.tsx new file mode 100644 index 0000000..60404d7 --- /dev/null +++ b/frontend/src/hooks/useWellnessState.tsx @@ -0,0 +1,40 @@ +import { createContext, useContext, useState, ReactNode } from 'react' + +interface WellnessState { + healthDeviceId: string | null + city: string + setHealthDeviceId: (id: string | null) => void + setCity: (city: string) => void +} + +const WellnessStateContext = createContext(undefined) + +export function WellnessStateProvider({ children }: { children: ReactNode }) { + const [healthDeviceId, setHealthDeviceId] = useState(null) + const [city, setCity] = useState('Skopje') + + return ( + + {children} + + ) +} + +export function useWellnessState() { + const context = useContext(WellnessStateContext) + if (context === undefined) { + throw new Error('useWellnessState must be used within WellnessStateProvider') + } + return context +} + + + + diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index be89246..b5a729c 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -8,27 +8,13 @@ export const apiClient = axios.create({ headers: { 'Content-Type': 'application/json', }, - withCredentials: true, // For session auth -}); - -// Add request interceptor for JWT token (if using JWT) -apiClient.interceptors.request.use((config) => { - const token = localStorage.getItem('access_token'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; }); // Add response interceptor for error handling apiClient.interceptors.response.use( (response) => response, (error) => { - if (error.response?.status === 401) { - // Handle unauthorized - redirect to login - localStorage.removeItem('access_token'); - // window.location.href = '/login'; - } + // Basic error handling - can be extended if needed return Promise.reject(error); } ); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 218a520..36cfaa8 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' import GridLayout from 'react-grid-layout' import 'react-grid-layout/css/styles.css' import { useDashboardConfig } from '../hooks' @@ -6,18 +6,35 @@ import { WidgetContainer } from '../components/widgets' import AddWidgetModal from '../components/AddWidgetModal' import EditWidgetModal from '../components/EditWidgetModal' +const GRID_COLUMNS = 5 +const GRID_MARGIN: [number, number] = [8, 6] +const ROW_HEIGHT = 90 +const HEIGHT_PADDING = 0 +const ROW_UNIT = ROW_HEIGHT + GRID_MARGIN[1] +const MAX_AUTO_ROWS = 6 + export default function Dashboard() { - const { config, addWidget, removeWidget, updateWidget, exportConfig, importConfig } = useDashboardConfig() + const { config, addWidget, removeWidget, updateWidget, exportConfig, importConfig, saveConfig } = useDashboardConfig() const [isModalOpen, setIsModalOpen] = useState(false) const [editingWidget, setEditingWidget] = useState(null) - const [gridWidth, setGridWidth] = useState(1200) + const [isSaving, setIsSaving] = useState(false) + const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle') + const [gridWidth, setGridWidth] = useState(() => { + if (typeof window !== 'undefined') { + return window.innerWidth + } + return GRID_COLUMNS * (ROW_HEIGHT + GRID_MARGIN[0]) + }) const gridContainerRef = useRef(null) // Update grid width on resize useEffect(() => { const updateWidth = () => { if (gridContainerRef.current) { - setGridWidth(gridContainerRef.current.offsetWidth) + const rect = gridContainerRef.current.getBoundingClientRect() + setGridWidth(rect.width) + } else if (typeof window !== 'undefined') { + setGridWidth(window.innerWidth) } } @@ -43,16 +60,44 @@ export default function Dashboard() { }) } - const layout = config.widgets.map((widget) => ({ - i: widget.id, - x: widget.position?.x || 0, - y: widget.position?.y || 0, - w: widget.position?.w || 1, - h: widget.position?.h || 2, - minW: 1, - minH: 1, - maxW: 4, - })) + const layout = config.widgets.map((widget) => { + const position = widget.position ?? { x: 0, y: Infinity, w: 1, h: 1 } + return { + i: widget.id, + x: position.x ?? 0, + y: position.y ?? Infinity, + w: Math.max(position.w ?? 1, 1), + h: Math.max(position.h ?? 1, 1), + minW: 1, + minH: 1, + maxW: GRID_COLUMNS, + } + }) + + const handleWidgetHeightChange = useCallback( + (widgetId: string, contentHeight: number) => { + const widget = config.widgets.find((w) => w.id === widgetId) + if (!widget) return + + const position = widget.position ?? { x: 0, y: Infinity, w: 1, h: 1 } + const currentRows = Math.max(position.h ?? 1, 1) + const desiredPixelHeight = contentHeight + HEIGHT_PADDING + const targetRows = Math.min( + MAX_AUTO_ROWS, + Math.max(1, Math.ceil(desiredPixelHeight / ROW_UNIT)) + ) + + if (Math.abs(targetRows - currentRows) >= 1) { + updateWidget(widgetId, { + position: { + ...position, + h: targetRows, + }, + }) + } + }, + [config.widgets, updateWidget] + ) const handleExport = () => { const json = exportConfig() @@ -83,6 +128,21 @@ export default function Dashboard() { reader.readAsText(file) } + const handleSaveDashboard = async () => { + setIsSaving(true) + setSaveStatus('idle') + try { + const success = await saveConfig() + setSaveStatus(success ? 'success' : 'error') + } catch (error) { + console.error('Failed to save dashboard configuration:', error) + setSaveStatus('error') + } finally { + setIsSaving(false) + setTimeout(() => setSaveStatus('idle'), 3000) + } + } + return (
@@ -92,7 +152,30 @@ export default function Dashboard() { Customize your view with modular widgets

-
+
+ + {saveStatus === 'success' && ( + Saved! + )} + {saveStatus === 'error' && ( + Save failed + )}
) : ( -
- - {config.widgets.map((widget) => ( -
- removeWidget(widget.id)} - onEdit={() => setEditingWidget(widget.id)} - /> -
- ))} -
-
+
+ + {config.widgets.map((widget) => ( +
+ removeWidget(widget.id)} + onEdit={() => setEditingWidget(widget.id)} + onHeightChange={(height: number) => handleWidgetHeightChange(widget.id, height)} + /> +
+ ))} +
+
)} end_time.replace(tzinfo=None): + continue + except: + event['start'] = 'TBD' + + # Extract location + location_match = re.search(r'LOCATION[^:]*:(.+?)(?:\r?\n|$)', event_text) + if location_match: + event['location'] = location_match.group(1).strip() + + if event.get('summary'): + events.append(event) + + # Sort by start time and limit + return events[:15] + + +# Dashboard Layout Views +class DashboardLayoutViewSet(viewsets.ModelViewSet): + """ViewSet for managing dashboard layouts (single-user system).""" + + serializer_class = DashboardLayoutSerializer + permission_classes = [permissions.AllowAny] # No auth required for single-user system + + def get_queryset(self): + """Return all layouts (single-user system).""" + return DashboardLayout.objects.all() + + @action(detail=False, methods=['get']) + def default(self, request): + """Get the default layout.""" + layout = DashboardLayout.get_default() + return Response(self.get_serializer(layout).data) + + @action(detail=True, methods=['post']) + def set_default(self, request, pk=None): + """Set a layout as the default.""" + layout = self.get_object() + # Unset other defaults + DashboardLayout.objects.filter(is_default=True).update(is_default=False) + # Set this one as default + layout.is_default = True + layout.save() + return Response(self.get_serializer(layout).data) + + diff --git a/iotDashboard/dashboard_models.py b/iotDashboard/dashboard_models.py new file mode 100644 index 0000000..4de5755 --- /dev/null +++ b/iotDashboard/dashboard_models.py @@ -0,0 +1,65 @@ +""" +Dashboard layout models for persistent storage in PostgreSQL. + +Single-user system: Stores dashboard configuration for the default user. +PostgreSQL is chosen over Redis because: +- Dashboard layouts are persistent configuration data (not cache) +- Need ACID guarantees for data integrity +- Low read/write frequency (performance difference negligible) +- Already using PostgreSQL for Django +""" + +from django.db import models +from django.utils import timezone + + +class DashboardLayout(models.Model): + """Stores dashboard configuration in PostgreSQL. + + Single-user system: Only one default layout is stored. + This is persistent configuration data that should survive server restarts. + PostgreSQL provides: + - ACID guarantees + - Complex queries + - Backup/restore capabilities + """ + + name = models.CharField( + max_length=255, + default='default', + unique=True, + help_text="Layout name (e.g., 'default', 'mobile', 'work')" + ) + config = models.JSONField( + help_text="Full dashboard configuration JSON" + ) + is_default = models.BooleanField( + default=True, + help_text="Whether this is the default layout" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "dashboard_layouts" + indexes = [ + models.Index(fields=["is_default"]), + models.Index(fields=["name"]), + ] + + def __str__(self): + return f"{self.name} (default: {self.is_default})" + + @classmethod + def get_default(cls): + """Get the default layout, or create one if it doesn't exist.""" + layout = cls.objects.filter(is_default=True).first() + if layout: + return layout + # Create default if none exists + return cls.objects.create( + name='default', + config={'widgets': [], 'layout': 'grid', 'refreshInterval': 30000}, + is_default=True + ) + diff --git a/iotDashboard/gpt_service_client.py b/iotDashboard/gpt_service_client.py index ecffa4a..ec32ab1 100644 --- a/iotDashboard/gpt_service_client.py +++ b/iotDashboard/gpt_service_client.py @@ -56,6 +56,75 @@ class GPTServiceClient: logger.error(f"GPT service health check failed: {e}") raise + async def generate_daily_briefing( + self, + briefing_type: Literal["schedule", "environment", "full"], + current_time: str, + indoor_data: Optional[Dict[str, Any]] = None, + outdoor_data: Optional[Dict[str, Any]] = None, + health_data: Optional[Dict[str, Any]] = None, + calendar_events: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + """ + Generate a daily briefing for office workers. + + Args: + briefing_type: 'schedule', 'environment', or 'full' + current_time: Current time in ISO format + indoor_data: Indoor environment readings + outdoor_data: Weather and air quality data + health_data: Health/fitness metrics + calendar_events: List of upcoming calendar events + + Returns: + Dict with status_emoji, status_line, insights, recommendations + + Raises: + GPTServiceError: If request fails + """ + payload = { + "briefing_type": briefing_type, + "current_time": current_time, + } + + if indoor_data: + payload["indoor_data"] = indoor_data + if outdoor_data: + payload["outdoor_data"] = outdoor_data + if health_data: + payload["health_data"] = health_data + if calendar_events: + payload["calendar_events"] = calendar_events + + try: + logger.info(f"Requesting {briefing_type} daily briefing") + response = await self.client.post("/daily-briefing", json=payload) + response.raise_for_status() + result = response.json() + logger.info(f"Daily briefing generated successfully") + return result + except httpx.HTTPStatusError as e: + error_detail = e.response.text + logger.error(f"GPT service returned error {e.response.status_code}: {error_detail}") + raise GPTServiceError( + message=f"GPT service error: {error_detail}", + status_code=e.response.status_code, + details={"response": error_detail} + ) + except httpx.RequestError as e: + logger.error(f"Failed to connect to GPT service: {e}") + raise GPTServiceError( + message=f"GPT service unavailable: {str(e)}", + status_code=503, + details={"error": str(e)} + ) + except Exception as e: + logger.error(f"Failed to generate daily briefing: {e}") + raise GPTServiceError( + message=f"Briefing generation failed: {str(e)}", + details={"error": str(e)} + ) + async def analyze_telemetry( self, telemetry_data: List[Dict[str, Any]], diff --git a/iotDashboard/health_insights.py b/iotDashboard/health_insights.py new file mode 100644 index 0000000..ed61b04 --- /dev/null +++ b/iotDashboard/health_insights.py @@ -0,0 +1,354 @@ +""" +Health Insights Calculator + +Correlates health metrics with environmental data to provide contextual insights. +Shows how weather and air quality affect health metrics. +""" + +from typing import Dict, List, Optional +from dataclasses import dataclass +from datetime import datetime, timedelta + + +@dataclass +class HealthMetrics: + """Current health metrics.""" + steps: int = 0 + active_calories: int = 0 + heart_rate: Optional[float] = None + resting_heart_rate: Optional[float] = None + sleep_duration: Optional[float] = None # minutes + timestamp: Optional[datetime] = None + + +@dataclass +class EnvironmentalContext: + """Environmental context for health insights.""" + temperature: Optional[float] = None + humidity: Optional[float] = None + pm25: Optional[float] = None + pm10: Optional[float] = None + air_quality_status: Optional[str] = None + weather_description: Optional[str] = None + + +@dataclass +class HealthInsight: + """Individual health insight.""" + metric: str + value: float + context: str + correlation: Optional[str] = None + recommendation: Optional[str] = None + + +@dataclass +class HealthInsightsResult: + """Health insights with environmental context.""" + health_metrics: Dict[str, float] + environmental_context: Dict[str, Optional[float]] + insights: List[HealthInsight] + correlations: List[str] + recommendations: List[str] + trend_indicators: List[str] + + +class HealthInsightsCalculator: + """Calculate contextual health insights from health and environmental data.""" + + # Thresholds for correlations + TEMP_HR_CORRELATION_THRESHOLD = 3.0 # bpm per degree C above 22°C + PM25_HR_THRESHOLD = 20.0 # μg/m³ - above this may affect HR + PM10_HR_THRESHOLD = 30.0 # μg/m³ - above this may affect HR + OPTIMAL_TEMP_FOR_ACTIVITY = (18.0, 22.0) # °C + + @staticmethod + def analyze_heart_rate( + hr: Optional[float], + resting_hr: Optional[float], + env: EnvironmentalContext + ) -> List[HealthInsight]: + """Analyze heart rate with environmental context.""" + insights = [] + + if hr is None: + return insights + + # Base insight + hr_insight = HealthInsight( + metric="Heart Rate", + value=hr, + context=f"Current: {hr:.0f} bpm" + ) + + # Compare with resting HR + if resting_hr is not None: + elevation = hr - resting_hr + if elevation > 20: + hr_insight.context += f" (elevated by {elevation:.0f} bpm from resting)" + if env.temperature is not None and env.temperature > 25: + hr_insight.correlation = f"High temperature ({env.temperature:.1f}°C) may be contributing to elevated HR" + hr_insight.recommendation = "Stay hydrated and avoid intense activity in heat" + elif elevation > 10: + hr_insight.context += f" (slightly elevated by {elevation:.0f} bpm)" + + # Temperature correlation + if env.temperature is not None: + if env.temperature > 25: + expected_hr_increase = (env.temperature - 22) * HealthInsightsCalculator.TEMP_HR_CORRELATION_THRESHOLD + if hr_insight.correlation is None: + hr_insight.correlation = f"Temperature ({env.temperature:.1f}°C) may increase HR by ~{expected_hr_increase:.0f} bpm" + elif env.temperature < 15: + hr_insight.correlation = f"Cool temperature ({env.temperature:.1f}°C) - HR may be lower than usual" + + # Air quality correlation + if env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD: + if hr_insight.correlation: + hr_insight.correlation += f". Poor air quality (PM2.5: {env.pm25:.1f} μg/m³) may also affect HR" + else: + hr_insight.correlation = f"Poor air quality (PM2.5: {env.pm25:.1f} μg/m³) may be affecting HR" + hr_insight.recommendation = "Consider indoor activity when air quality improves" + + if env.pm10 is not None and env.pm10 > HealthInsightsCalculator.PM10_HR_THRESHOLD: + if hr_insight.correlation and "air quality" not in hr_insight.correlation.lower(): + hr_insight.correlation += f". High PM10 ({env.pm10:.1f} μg/m³) may affect breathing" + + insights.append(hr_insight) + return insights + + @staticmethod + def analyze_activity( + steps: int, + calories: int, + env: EnvironmentalContext + ) -> List[HealthInsight]: + """Analyze activity levels with environmental context.""" + insights = [] + + # Steps insight + steps_insight = HealthInsight( + metric="Steps", + value=steps, + context=f"Today: {steps:,} steps" + ) + + # Activity level assessment + if steps >= 10000: + steps_insight.context += " - Excellent activity level!" + elif steps >= 7500: + steps_insight.context += " - Good activity level" + elif steps >= 5000: + steps_insight.context += " - Moderate activity" + else: + steps_insight.context += " - Low activity today" + if env.temperature is not None: + temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY + if temp_min <= env.temperature <= temp_max: + steps_insight.recommendation = f"Perfect weather ({env.temperature:.1f}°C) - great time for outdoor activity!" + elif env.temperature > temp_max: + steps_insight.recommendation = f"Warm weather ({env.temperature:.1f}°C) - consider early morning or evening activity" + else: + steps_insight.recommendation = f"Cool weather ({env.temperature:.1f}°C) - dress warmly for outdoor activity" + + # Weather correlation + if env.weather_description: + if "clear" in env.weather_description.lower() or "sunny" in env.weather_description.lower(): + if steps < 5000: + steps_insight.correlation = "Clear weather - perfect for outdoor activity" + elif "rain" in env.weather_description.lower() or "snow" in env.weather_description.lower(): + steps_insight.correlation = f"Weather: {env.weather_description} - may limit outdoor activity" + + insights.append(steps_insight) + + # Calories insight + calories_insight = HealthInsight( + metric="Active Calories", + value=calories, + context=f"Today: {calories:,} kcal" + ) + + if calories >= 500: + calories_insight.context += " - Great calorie burn!" + elif calories >= 300: + calories_insight.context += " - Good calorie burn" + + insights.append(calories_insight) + return insights + + @staticmethod + def generate_correlations( + health: HealthMetrics, + env: EnvironmentalContext + ) -> List[str]: + """Generate correlation statements.""" + correlations = [] + + # HR vs Temperature + if health.heart_rate is not None and env.temperature is not None: + if env.temperature > 25: + correlations.append(f"Your HR ({health.heart_rate:.0f} bpm) may be elevated due to high temperature ({env.temperature:.1f}°C)") + elif env.temperature < 15: + correlations.append(f"Cool temperature ({env.temperature:.1f}°C) may result in lower HR than usual") + + # HR vs Air Quality + if health.heart_rate is not None: + if env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD: + correlations.append(f"Elevated HR may be related to poor air quality (PM2.5: {env.pm25:.1f} μg/m³)") + if env.pm10 is not None and env.pm10 > HealthInsightsCalculator.PM10_HR_THRESHOLD: + correlations.append(f"High PM10 ({env.pm10:.1f} μg/m³) may affect breathing and HR") + + # Activity vs Weather + if health.steps > 0 and env.weather_description: + if "clear" in env.weather_description.lower() or "sunny" in env.weather_description.lower(): + if health.steps >= 10000: + correlations.append("Great activity on a clear day!") + elif "rain" in env.weather_description.lower(): + correlations.append("Rainy weather may have limited outdoor activity") + + # Activity vs Temperature + if health.steps > 0 and env.temperature is not None: + temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY + if temp_min <= env.temperature <= temp_max: + if health.steps >= 10000: + correlations.append(f"Optimal temperature ({env.temperature:.1f}°C) for high activity") + elif env.temperature > temp_max: + correlations.append(f"Warm weather ({env.temperature:.1f}°C) may reduce activity levels") + + return correlations + + @staticmethod + def generate_recommendations( + health: HealthMetrics, + env: EnvironmentalContext + ) -> List[str]: + """Generate personalized recommendations.""" + recommendations = [] + + # Activity recommendations + if health.steps < 5000: + if env.temperature is not None: + temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY + if temp_min <= env.temperature <= temp_max: + recommendations.append(f"Perfect weather ({env.temperature:.1f}°C) - great time for a walk or run!") + elif env.temperature > temp_max: + recommendations.append(f"Warm weather ({env.temperature:.1f}°C) - consider early morning or evening activity") + else: + recommendations.append(f"Cool weather ({env.temperature:.1f}°C) - dress warmly for outdoor activity") + + # Air quality recommendations + if env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD: + recommendations.append(f"Air quality is moderate (PM2.5: {env.pm25:.1f} μg/m³) - sensitive individuals should limit outdoor activity") + + if env.pm10 is not None and env.pm10 > HealthInsightsCalculator.PM10_HR_THRESHOLD: + recommendations.append(f"High PM10 ({env.pm10:.1f} μg/m³) - consider indoor alternatives") + + # HR recommendations + if health.heart_rate is not None and health.resting_heart_rate is not None: + elevation = health.heart_rate - health.resting_heart_rate + if elevation > 20: + if env.temperature is not None and env.temperature > 25: + recommendations.append("Elevated HR in hot weather - stay hydrated and rest if needed") + elif env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD: + recommendations.append("Elevated HR may be related to air quality - consider indoor rest") + else: + recommendations.append("Elevated HR - may indicate stress or fatigue, consider rest") + + # Goal progress + if health.steps > 0: + goal_progress = (health.steps / 10000) * 100 + if goal_progress < 50: + recommendations.append(f"Daily goal: {goal_progress:.0f}% complete - good time for activity") + elif goal_progress >= 100: + recommendations.append("Daily goal exceeded - excellent work!") + + return recommendations + + @staticmethod + def generate_trend_indicators( + health: HealthMetrics, + env: EnvironmentalContext + ) -> List[str]: + """Generate trend indicators (simplified - no historical data yet).""" + indicators = [] + + # Activity trends (would need historical data for real trends) + if health.steps >= 10000: + indicators.append("High activity level today") + elif health.steps >= 7500: + indicators.append("Good activity level") + + # Environmental impact indicators + if env.temperature is not None: + temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY + if temp_min <= env.temperature <= temp_max: + indicators.append("Optimal temperature for activity") + elif env.temperature > temp_max: + indicators.append("Warm weather may reduce activity") + + if env.pm25 is not None and env.pm25 < 15: + indicators.append("Good air quality - ideal for outdoor activity") + elif env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD: + indicators.append("Moderate air quality - may affect activity") + + return indicators + + @classmethod + def calculate( + cls, + health: HealthMetrics, + env: EnvironmentalContext + ) -> HealthInsightsResult: + """ + Calculate health insights with environmental context. + + Args: + health: Current health metrics + env: Environmental context + + Returns: + HealthInsightsResult with insights, correlations, and recommendations + """ + # Analyze individual metrics + insights = [] + insights.extend(cls.analyze_heart_rate(health.heart_rate, health.resting_heart_rate, env)) + insights.extend(cls.analyze_activity(health.steps, health.active_calories, env)) + + # Generate correlations + correlations = cls.generate_correlations(health, env) + + # Generate recommendations + recommendations = cls.generate_recommendations(health, env) + + # Generate trend indicators + trend_indicators = cls.generate_trend_indicators(health, env) + + # Prepare health metrics dict + health_metrics = { + "steps": health.steps, + "active_calories": health.active_calories, + "heart_rate": health.heart_rate, + "resting_heart_rate": health.resting_heart_rate, + } + + # Prepare environmental context dict + environmental_context = { + "temperature": env.temperature, + "humidity": env.humidity, + "pm25": env.pm25, + "pm10": env.pm10, + "air_quality_status": env.air_quality_status, + "weather_description": env.weather_description, + } + + return HealthInsightsResult( + health_metrics=health_metrics, + environmental_context=environmental_context, + insights=insights, + correlations=correlations, + recommendations=recommendations, + trend_indicators=trend_indicators, + ) + + + + diff --git a/iotDashboard/migrations/0002_sensortype_remove_device_humidity_and_more.py b/iotDashboard/migrations/0002_sensortype_remove_device_humidity_and_more.py index c5bfbf4..87638bc 100644 --- a/iotDashboard/migrations/0002_sensortype_remove_device_humidity_and_more.py +++ b/iotDashboard/migrations/0002_sensortype_remove_device_humidity_and_more.py @@ -10,73 +10,8 @@ class Migration(migrations.Migration): ] operations = [ - migrations.CreateModel( - name="SensorType", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=50, unique=True)), - ("unit", models.CharField(max_length=20)), - ( - "protocol", - models.CharField( - choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20 - ), - ), - ("topic", models.CharField(blank=True, max_length=100, null=True)), - ("endpoint", models.CharField(blank=True, max_length=100, null=True)), - ], - ), - migrations.RemoveField( - model_name="device", - name="humidity", - ), - migrations.RemoveField( - model_name="device", - name="temperature", - ), - migrations.AlterField( - model_name="device", - name="protocol", - field=models.CharField( - choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20 - ), - ), - migrations.CreateModel( - name="Sensor", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("enabled", models.BooleanField(default=True)), - ( - "device", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="sensors", - to="iotDashboard.device", - ), - ), - ( - "type", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="iotDashboard.sensortype", - ), - ), - ], - ), - ] + # Note: Device model is now managed=False, so we don't modify it in migrations + # SensorType and Sensor models are also not in current models.py + # This migration is kept for historical reference but operations are removed + # to prevent errors with managed=False models + ] \ No newline at end of file diff --git a/iotDashboard/migrations/0003_create_dashboard_layouts.py b/iotDashboard/migrations/0003_create_dashboard_layouts.py new file mode 100644 index 0000000..8a84ac3 --- /dev/null +++ b/iotDashboard/migrations/0003_create_dashboard_layouts.py @@ -0,0 +1,82 @@ +# Generated by Django 5.2.7 on 2025-11-19 22:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('iotDashboard', '0002_sensortype_remove_device_humidity_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='DeviceCertificate', + fields=[ + ('id', models.CharField(max_length=255, primary_key=True, serialize=False)), + ('certificate_pem', models.TextField()), + ('private_key_pem', models.TextField(blank=True, null=True)), + ('issued_at', models.DateTimeField()), + ('expires_at', models.DateTimeField()), + ('revoked_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'db_table': 'device_certificates', + 'managed': False, + }, + ), + migrations.CreateModel( + name='DeviceCredential', + fields=[ + ('id', models.CharField(max_length=255, primary_key=True, serialize=False)), + ('credential_type', models.CharField(max_length=50)), + ('credential_hash', models.TextField()), + ('created_at', models.DateTimeField()), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('revoked_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'db_table': 'device_credentials', + 'managed': False, + }, + ), + migrations.CreateModel( + name='Telemetry', + fields=[ + ('time', models.DateTimeField(primary_key=True, serialize=False)), + ('device_id', models.CharField(db_column='device_id', max_length=255)), + ('metric', models.CharField(max_length=255)), + ('value', models.FloatField()), + ('unit', models.CharField(blank=True, max_length=50, null=True)), + ], + options={ + 'db_table': 'telemetry', + 'managed': False, + }, + ), + migrations.AlterModelOptions( + name='device', + options={'managed': False}, + ), + migrations.CreateModel( + name='DashboardLayout', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='default', help_text="Layout name (e.g., 'default', 'mobile', 'work')", max_length=255, unique=True)), + ('config', models.JSONField(help_text='Full dashboard configuration JSON')), + ('is_default', models.BooleanField(default=True, help_text='Whether this is the default layout')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'dashboard_layouts', + 'indexes': [models.Index(fields=['is_default'], name='dashboard_l_is_defa_033b71_idx'), models.Index(fields=['name'], name='dashboard_l_name_c36020_idx')], + }, + ), + migrations.DeleteModel( + name='Sensor', + ), + migrations.DeleteModel( + name='SensorType', + ), + ] diff --git a/iotDashboard/run_suitability.py b/iotDashboard/run_suitability.py new file mode 100644 index 0000000..7f1321a --- /dev/null +++ b/iotDashboard/run_suitability.py @@ -0,0 +1,333 @@ +""" +Run Suitability Calculator + +Combines weather, air quality, and health data to determine if conditions are suitable for running. +Provides time-based recommendations and personalized insights. +""" + +from typing import Dict, List, Optional, Tuple +from dataclasses import dataclass +from datetime import datetime, time + + +@dataclass +class WeatherData: + """Weather data for run suitability analysis.""" + temperature: float + apparent_temperature: float + wind_speed: float # km/h + precipitation: float # mm + rain: float # mm + weather_code: int + humidity: float # % + cloud_cover: float # % + + +@dataclass +class AirQualityData: + """Air quality data for run suitability analysis.""" + pm25: Optional[float] = None + pm10: Optional[float] = None + status: str = "Unknown" + + +@dataclass +class HealthData: + """Health data for run suitability analysis.""" + steps_today: int = 0 + active_calories: int = 0 + heart_rate: Optional[float] = None + resting_heart_rate: Optional[float] = None + daily_goal_steps: int = 10000 # Default goal + + +@dataclass +class RunSuitabilityScore: + """Run suitability results with breakdown.""" + status: str # "GO", "MODERATE", "NO" + overall_score: float # 0-100 + weather_score: float + air_quality_score: float + health_score: float + primary_reason: str + detailed_insights: List[str] + time_recommendations: List[str] + suggestions: List[str] + + +class RunSuitabilityCalculator: + """Calculate run suitability from weather, air quality, and health data.""" + + # Optimal ranges for running + OPTIMAL_TEMP_RANGE = (10.0, 20.0) # °C - ideal for running + ACCEPTABLE_TEMP_RANGE = (5.0, 25.0) # °C - acceptable but not ideal + MAX_WIND_SPEED = 25.0 # km/h - above this is too windy + MAX_PRECIPITATION = 0.5 # mm - light drizzle OK, more is not + MAX_PM25 = 35.0 # μg/m³ - WHO unhealthy threshold + MAX_PM10 = 50.0 # μg/m³ - WHO unhealthy threshold + MODERATE_PM25 = 15.0 # μg/m³ - moderate threshold + MODERATE_PM10 = 20.0 # μg/m³ - moderate threshold + + # Time-based recommendations + BEST_TIMES = [ + (time(6, 0), time(8, 0), "Early morning - cool temperatures, low pollution"), + (time(18, 0), time(20, 0), "Evening - comfortable temperatures, good visibility"), + ] + + @staticmethod + def calculate_weather_score(weather: WeatherData) -> Tuple[float, List[str]]: + """Calculate weather suitability score (0-100).""" + score = 100.0 + issues = [] + + # Temperature scoring + temp = weather.temperature + if RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[0] <= temp <= RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[1]: + # Perfect temperature + pass + elif RunSuitabilityCalculator.ACCEPTABLE_TEMP_RANGE[0] <= temp <= RunSuitabilityCalculator.ACCEPTABLE_TEMP_RANGE[1]: + # Acceptable but not ideal + if temp < RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[0]: + deviation = RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[0] - temp + score -= deviation * 5 # -5 points per degree below optimal + issues.append(f"Cool ({temp:.1f}°C) - dress warmly") + else: + deviation = temp - RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[1] + score -= deviation * 3 # -3 points per degree above optimal + issues.append(f"Warm ({temp:.1f}°C) - stay hydrated") + else: + # Too cold or too hot + if temp < RunSuitabilityCalculator.ACCEPTABLE_TEMP_RANGE[0]: + score -= 50 + issues.append(f"Too cold ({temp:.1f}°C) - not suitable for running") + else: + score -= 50 + issues.append(f"Too hot ({temp:.1f}°C) - risk of heat exhaustion") + + # Wind scoring + if weather.wind_speed > RunSuitabilityCalculator.MAX_WIND_SPEED: + score -= 30 + issues.append(f"High wind ({weather.wind_speed:.1f} km/h) - difficult conditions") + elif weather.wind_speed > 15.0: + score -= 10 + issues.append(f"Moderate wind ({weather.wind_speed:.1f} km/h) - may affect pace") + + # Precipitation scoring + if weather.precipitation > RunSuitabilityCalculator.MAX_PRECIPITATION: + score -= 40 + if weather.rain > 0: + issues.append(f"Rain ({weather.rain:.1f} mm) - slippery conditions") + else: + issues.append(f"Precipitation ({weather.precipitation:.1f} mm) - wet conditions") + + # Weather code (snow, thunderstorms, etc.) + if weather.weather_code >= 71: # Snow + score -= 50 + issues.append("Snow - unsafe for running") + elif weather.weather_code >= 95: # Thunderstorm + score -= 60 + issues.append("Thunderstorm - dangerous conditions") + + score = max(0, min(100, score)) + return score, issues + + @staticmethod + def calculate_air_quality_score(air_quality: AirQualityData) -> Tuple[float, List[str]]: + """Calculate air quality suitability score (0-100).""" + score = 100.0 + issues = [] + + # PM2.5 scoring + if air_quality.pm25 is not None: + if air_quality.pm25 > RunSuitabilityCalculator.MAX_PM25: + score -= 50 + issues.append(f"Poor air quality (PM2.5: {air_quality.pm25:.1f} μg/m³) - unhealthy for exercise") + elif air_quality.pm25 > RunSuitabilityCalculator.MODERATE_PM25: + score -= 20 + issues.append(f"Moderate air quality (PM2.5: {air_quality.pm25:.1f} μg/m³) - sensitive individuals should avoid") + + # PM10 scoring + if air_quality.pm10 is not None: + if air_quality.pm10 > RunSuitabilityCalculator.MAX_PM10: + score -= 50 + issues.append(f"Poor air quality (PM10: {air_quality.pm10:.1f} μg/m³) - unhealthy for exercise") + elif air_quality.pm10 > RunSuitabilityCalculator.MODERATE_PM10: + score -= 20 + issues.append(f"Moderate air quality (PM10: {air_quality.pm10:.1f} μg/m³) - may affect breathing") + + # Status-based scoring + status_lower = air_quality.status.lower() + if "unhealthy" in status_lower or "hazardous" in status_lower: + score = min(score, 30) + elif "moderate" in status_lower or "sensitive" in status_lower: + score = min(score, 70) + + score = max(0, min(100, score)) + return score, issues + + @staticmethod + def calculate_health_score(health: HealthData) -> Tuple[float, List[str]]: + """Calculate health context score (0-100).""" + score = 100.0 + insights = [] + + # Check if user is already very active today + if health.steps_today > 15000: + score -= 10 + insights.append("High activity today - consider rest or light activity") + elif health.steps_today > 10000: + insights.append(f"Good activity level ({health.steps_today:,} steps) - ready for a run") + + # Check daily goal progress + goal_progress = (health.steps_today / health.daily_goal_steps) * 100 if health.daily_goal_steps > 0 else 0 + if goal_progress < 50: + insights.append(f"Daily goal: {goal_progress:.0f}% complete - good time for a run") + elif goal_progress > 100: + insights.append("Daily goal exceeded - great job!") + + # Heart rate context + if health.heart_rate is not None: + if health.heart_rate > 100: + score -= 15 + insights.append(f"Elevated heart rate ({health.heart_rate:.0f} bpm) - may need rest") + elif health.heart_rate > 85: + score -= 5 + insights.append(f"Slightly elevated HR ({health.heart_rate:.0f} bpm) - consider lighter activity") + + if health.resting_heart_rate is not None and health.heart_rate is not None: + hr_elevation = health.heart_rate - health.resting_heart_rate + if hr_elevation > 20: + insights.append("Heart rate significantly elevated - may indicate stress or fatigue") + + score = max(0, min(100, score)) + return score, insights + + @staticmethod + def get_time_recommendations(current_time: Optional[datetime] = None) -> List[str]: + """Get time-based recommendations for running.""" + if current_time is None: + current_time = datetime.now() + + current_hour = current_time.hour + recommendations = [] + + # Check if current time is in optimal range + for start_time, end_time, description in RunSuitabilityCalculator.BEST_TIMES: + if start_time.hour <= current_hour < end_time.hour: + recommendations.append(f"Current time is ideal: {description}") + else: + # Calculate hours until next optimal time + if current_hour < start_time.hour: + hours_until = start_time.hour - current_hour + recommendations.append(f"Best time in {hours_until} hours ({start_time.strftime('%I:%M %p')}): {description}") + elif current_hour >= end_time.hour: + # Next optimal time is tomorrow + next_start = RunSuitabilityCalculator.BEST_TIMES[0][0] if start_time == RunSuitabilityCalculator.BEST_TIMES[-1][0] else RunSuitabilityCalculator.BEST_TIMES[1][0] + hours_until = (24 - current_hour) + next_start.hour + recommendations.append(f"Best time in {hours_until} hours ({next_start.strftime('%I:%M %p')}): {description}") + + return recommendations + + @classmethod + def calculate( + cls, + weather: WeatherData, + air_quality: AirQualityData, + health: HealthData, + current_time: Optional[datetime] = None + ) -> RunSuitabilityScore: + """ + Calculate overall run suitability. + + Args: + weather: Weather data + air_quality: Air quality data + health: Health data + current_time: Current time for recommendations (default: now) + + Returns: + RunSuitabilityScore with status, scores, and insights + """ + if current_time is None: + current_time = datetime.now() + + # Calculate component scores + weather_score, weather_issues = cls.calculate_weather_score(weather) + air_quality_score, air_quality_issues = cls.calculate_air_quality_score(air_quality) + health_score, health_insights = cls.calculate_health_score(health) + + # Weighted overall score + overall_score = ( + weather_score * 0.40 + + air_quality_score * 0.35 + + health_score * 0.25 + ) + + # Determine status + if overall_score >= 75: + status = "GO" + elif overall_score >= 50: + status = "MODERATE" + else: + status = "NO" + + # Primary reason + primary_reason = "Conditions are perfect for running!" + if status == "NO": + if weather_score < 50: + primary_reason = weather_issues[0] if weather_issues else "Weather conditions are poor" + elif air_quality_score < 50: + primary_reason = air_quality_issues[0] if air_quality_issues else "Air quality is poor" + else: + primary_reason = "Conditions are not suitable for running" + elif status == "MODERATE": + if weather_score < 70: + primary_reason = weather_issues[0] if weather_issues else "Weather conditions are moderate" + elif air_quality_score < 70: + primary_reason = air_quality_issues[0] if air_quality_issues else "Air quality is moderate" + else: + primary_reason = "Conditions are okay, but not ideal" + + # Combine all insights + detailed_insights = [] + detailed_insights.extend(weather_issues) + detailed_insights.extend(air_quality_issues) + detailed_insights.extend(health_insights) + + # Time recommendations + time_recommendations = cls.get_time_recommendations(current_time) + + # Suggestions + suggestions = [] + if status == "GO": + suggestions.append("Perfect conditions - enjoy your run!") + if health.steps_today < health.daily_goal_steps * 0.5: + suggestions.append("Great time to work toward your daily step goal") + elif status == "MODERATE": + suggestions.append("Conditions are acceptable - consider shorter or easier route") + if weather_score < 70: + suggestions.append("Dress appropriately for weather conditions") + if air_quality_score < 70: + suggestions.append("Sensitive individuals should consider indoor alternatives") + else: + suggestions.append("Consider indoor workout or wait for better conditions") + if weather_score < 50: + suggestions.append("Check weather forecast for better times") + if air_quality_score < 50: + suggestions.append("Air quality should improve later - check back in a few hours") + + return RunSuitabilityScore( + status=status, + overall_score=round(overall_score, 1), + weather_score=round(weather_score, 1), + air_quality_score=round(air_quality_score, 1), + health_score=round(health_score, 1), + primary_reason=primary_reason, + detailed_insights=detailed_insights, + time_recommendations=time_recommendations, + suggestions=suggestions, + ) + + + + diff --git a/iotDashboard/settings.py b/iotDashboard/settings.py index 0d5124e..f4be685 100644 --- a/iotDashboard/settings.py +++ b/iotDashboard/settings.py @@ -50,11 +50,13 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.staticfiles", "rest_framework", + "corsheaders", "iotDashboard", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "corsheaders.middleware.CorsMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -150,4 +152,20 @@ REST_FRAMEWORK = { 'DEFAULT_PARSER_CLASSES': [ 'rest_framework.parsers.JSONParser', ], + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.SessionAuthentication', # For admin only + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.AllowAny', # Single-user system, no auth required + ], } + +# CORS Settings (for React frontend) +CORS_ALLOWED_ORIGINS = [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:3000", + "http://127.0.0.1:3000", +] + +CORS_ALLOW_CREDENTIALS = True