mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 01:06:24 +00:00
Added Calendar viewer and Environment overview with AI, Health overviewer
This commit is contained in:
@@ -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 }) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<span>IoT Dashboard</span>
|
||||
<span>Lyncis</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -81,6 +82,7 @@ function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WellnessStateProvider>
|
||||
<BrowserRouter>
|
||||
<Toaster position="top-right" />
|
||||
<Routes>
|
||||
@@ -90,6 +92,7 @@ function App() {
|
||||
<Route path="/devices/:id" element={<AppLayout><DeviceDetail /></AppLayout>} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</WellnessStateProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -146,3 +146,150 @@ export const weatherApi = {
|
||||
sensor_count: number;
|
||||
}>('/weather/air_quality/', { params: { city } }),
|
||||
};
|
||||
|
||||
// Dashboard Layout API
|
||||
export const dashboardLayoutApi = {
|
||||
getAll: () =>
|
||||
apiClient.get<Array<{
|
||||
id: number
|
||||
name: string
|
||||
config: any
|
||||
is_default: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>>('/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<string, number> | null;
|
||||
outdoor: Record<string, number | string | null> | null;
|
||||
health: Record<string, number> | 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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -18,8 +18,12 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
|
||||
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')
|
||||
// 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
|
||||
<div className="text-xs opacity-70">Multi-factor</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'run-suitability' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('run-suitability')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Run Suitability</div>
|
||||
<div className="text-xs opacity-70">Go / No Go</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'health-stats' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('health-stats')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Health Stats</div>
|
||||
<div className="text-xs opacity-70">Steps & HR</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'calendar' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('calendar')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10m-12 8h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Calendar</div>
|
||||
<div className="text-xs opacity-70">Agenda (iCal)</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'daily-briefing' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('daily-briefing')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Daily Briefing</div>
|
||||
<div className="text-xs opacity-70">AI Summary</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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' && (
|
||||
<div className="space-y-4">
|
||||
{/* City input for wellness widgets */}
|
||||
{['run-suitability', 'health-stats'].includes(widgetType) && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">City (for weather/air quality)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder="Enter city name (e.g., Skopje)"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
/>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">
|
||||
Used for weather and air quality context
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Select Device</span>
|
||||
@@ -305,6 +430,10 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Metric Selection - Skip for widgets with hardcoded metrics */}
|
||||
{!['run-suitability', 'health-stats'].includes(widgetType) && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Select Metric(s)</span>
|
||||
@@ -347,6 +476,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-ghost" onClick={() => setStep(1)}>
|
||||
@@ -354,8 +484,12 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
|
||||
onClick={() => setStep(3)}
|
||||
disabled={selectedDevices.length === 0 || selectedMetrics.length === 0}
|
||||
disabled={
|
||||
(needsDevice && selectedDevices.length === 0) ||
|
||||
(needsMetrics && selectedMetrics.length === 0)
|
||||
}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
@@ -402,6 +536,194 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : widgetType === 'calendar' ? (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">iCal URL</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showCalendarUrl ? 'text' : 'password'}
|
||||
className="input input-bordered flex-1"
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={calendarUrl}
|
||||
onChange={(e) => setCalendarUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setShowCalendarUrl((prev) => !prev)}
|
||||
>
|
||||
{showCalendarUrl ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">Link is saved with the widget configuration</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Agenda Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={calendarRangeHours}
|
||||
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={24}>Next 24 hours</option>
|
||||
<option value={72}>Next 3 days</option>
|
||||
<option value={168}>Next 7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Widget Title (Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder="Calendar Agenda"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : widgetType === 'daily-briefing' ? (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Briefing Type</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'schedule' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('schedule')}
|
||||
>
|
||||
📅 Schedule
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'environment' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('environment')}
|
||||
>
|
||||
🌡️ Environment
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'full' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('full')}
|
||||
>
|
||||
✨ Full
|
||||
</button>
|
||||
</div>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">
|
||||
{briefingType === 'schedule' && 'Focus on your calendar and daily activity'}
|
||||
{briefingType === 'environment' && 'Focus on indoor/outdoor conditions for productivity'}
|
||||
{briefingType === 'full' && 'Complete briefing with all insights combined'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">City (for weather/air quality)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder="Enter city name (e.g., Skopje)"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(briefingType === 'schedule' || briefingType === 'full') && (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Calendar URL (Optional)</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showCalendarUrl ? 'text' : 'password'}
|
||||
className="input input-bordered flex-1"
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={calendarUrl}
|
||||
onChange={(e) => setCalendarUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setShowCalendarUrl((prev) => !prev)}
|
||||
>
|
||||
{showCalendarUrl ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">Optional: Include calendar events in your briefing</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{calendarUrl && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Calendar Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={calendarRangeHours}
|
||||
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={24}>Next 24 hours</option>
|
||||
<option value={72}>Next 3 days</option>
|
||||
<option value={168}>Next 7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{devices.length > 0 && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Health Device (Optional)</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={selectedDevices[0] || ''}
|
||||
onChange={(e) => setSelectedDevices(e.target.value ? [e.target.value] : [])}
|
||||
>
|
||||
<option value="">No health device</option>
|
||||
{devices.map((device) => (
|
||||
<option key={device.id} value={device.id}>
|
||||
{device.name} ({device.location || 'No location'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">Optional: Include step count and activity data</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Widget Title (Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder={`${briefingType.charAt(0).toUpperCase() + briefingType.slice(1)} Briefing`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Original configuration for sensor-based widgets
|
||||
<>
|
||||
@@ -478,14 +800,23 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold">Widget Summary</div>
|
||||
<div>Type: <span className="badge badge-sm">{widgetType}</span></div>
|
||||
<div>Device: {devices.find(d => d.id === selectedDevices[0])?.name}</div>
|
||||
<div>Metrics: {selectedMetrics.join(', ')}</div>
|
||||
<div>Device: {needsDevice ? (devices.find(d => d.id === selectedDevices[0])?.name || 'Not selected') : 'Not required'}</div>
|
||||
<div>Metrics: {needsMetrics ? (selectedMetrics.join(', ') || 'Not selected') : 'Not required'}</div>
|
||||
<div>Size: {widgetWidth} × {widgetHeight}</div>
|
||||
{widgetType === 'calendar' && (
|
||||
<div>Range: Next {calendarRangeHours}h</div>
|
||||
)}
|
||||
{widgetType === 'daily-briefing' && (
|
||||
<div>Briefing: {briefingType.charAt(0).toUpperCase() + briefingType.slice(1)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-ghost" onClick={() => setStep(2)}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setStep((widgetType === 'weather' || widgetType === 'air-quality' || widgetType === 'calendar' || widgetType === 'daily-briefing') ? 1 : 2)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleSubmit}>
|
||||
|
||||
@@ -12,44 +12,140 @@ interface EditWidgetModalProps {
|
||||
|
||||
export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: EditWidgetModalProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState('')
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
|
||||
const [timeframeHours, setTimeframeHours] = useState(24)
|
||||
const [widgetWidth, setWidgetWidth] = useState(1)
|
||||
const [widgetHeight, setWidgetHeight] = useState(2)
|
||||
const [calendarUrl, setCalendarUrl] = useState('')
|
||||
const [showCalendarUrl, setShowCalendarUrl] = useState(false)
|
||||
const [calendarRangeHours, setCalendarRangeHours] = useState(72)
|
||||
const [briefingType, setBriefingType] = useState<'schedule' | 'environment' | 'full'>('full')
|
||||
const [city, setCity] = useState('Skopje')
|
||||
|
||||
// Fetch metrics for the widget's device
|
||||
const { data: deviceMetricsData } = useQuery({
|
||||
queryKey: ['device-metrics', widget?.deviceIds[0]],
|
||||
// Fetch all devices
|
||||
const { data: devicesData } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: async () => {
|
||||
if (!widget?.deviceIds[0]) return []
|
||||
const response = await devicesApi.getMetrics(widget.deviceIds[0])
|
||||
return response.data.metrics
|
||||
const response = await devicesApi.getAll()
|
||||
return response.data.results
|
||||
},
|
||||
enabled: !!widget?.deviceIds[0] && isOpen,
|
||||
enabled: isOpen,
|
||||
})
|
||||
|
||||
const availableMetrics = deviceMetricsData || []
|
||||
// Fetch metrics for the selected device
|
||||
const { data: deviceMetricsData } = useQuery({
|
||||
queryKey: ['device-metrics', selectedDeviceId],
|
||||
queryFn: async () => {
|
||||
if (!selectedDeviceId) return []
|
||||
const response = await devicesApi.getMetrics(selectedDeviceId)
|
||||
return response.data.metrics
|
||||
},
|
||||
enabled: !!selectedDeviceId && isOpen,
|
||||
})
|
||||
|
||||
const availableMetrics = Array.isArray(deviceMetricsData) ? deviceMetricsData : []
|
||||
const devices = Array.isArray(devicesData) ? devicesData : []
|
||||
|
||||
// Load widget data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && widget) {
|
||||
setTitle(widget.title || '')
|
||||
setSelectedDeviceId(widget.deviceIds[0] || widget.briefing?.healthDeviceId || '')
|
||||
setSelectedMetrics(widget.metricIds || [])
|
||||
setTimeframeHours(widget.timeframe?.hours || 24)
|
||||
setWidgetWidth(widget.position?.w || 1)
|
||||
setWidgetHeight(widget.position?.h || 2)
|
||||
setCalendarUrl(widget.calendar?.icalUrl || widget.briefing?.calendarUrl || '')
|
||||
setCalendarRangeHours(widget.calendar?.timeRangeHours || widget.briefing?.calendarRangeHours || 72)
|
||||
setShowCalendarUrl(false)
|
||||
setBriefingType((widget.briefing?.briefingType as 'schedule' | 'environment' | 'full') || 'full')
|
||||
setCity(widget.visualization?.city || widget.briefing?.city || 'Skopje')
|
||||
}
|
||||
}, [isOpen, widget])
|
||||
|
||||
// Reset metrics when device changes
|
||||
useEffect(() => {
|
||||
if (selectedDeviceId && widget && selectedDeviceId !== widget.deviceIds[0]) {
|
||||
setSelectedMetrics([])
|
||||
}
|
||||
}, [selectedDeviceId, widget])
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!widget || selectedMetrics.length === 0) {
|
||||
if (!widget) return
|
||||
|
||||
const needsDevice = !['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type)
|
||||
const needsMetrics = !['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type)
|
||||
|
||||
if (widget.type === 'calendar') {
|
||||
if (!calendarUrl.trim()) {
|
||||
alert('Please provide an iCal URL')
|
||||
return
|
||||
}
|
||||
|
||||
onSave(widget.id, {
|
||||
title,
|
||||
calendar: {
|
||||
icalUrl: calendarUrl.trim(),
|
||||
timeRangeHours: calendarRangeHours,
|
||||
},
|
||||
position: {
|
||||
...widget.position,
|
||||
x: widget.position?.x || 0,
|
||||
y: widget.position?.y || 0,
|
||||
w: widgetWidth,
|
||||
h: widgetHeight,
|
||||
},
|
||||
})
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (widget.type === 'daily-briefing') {
|
||||
if (!city.trim()) {
|
||||
alert('Please enter a city')
|
||||
return
|
||||
}
|
||||
|
||||
onSave(widget.id, {
|
||||
title,
|
||||
visualization: {
|
||||
...widget.visualization,
|
||||
city,
|
||||
},
|
||||
briefing: {
|
||||
briefingType,
|
||||
city,
|
||||
healthDeviceId: selectedDeviceId || undefined,
|
||||
calendarUrl: calendarUrl.trim() || undefined,
|
||||
calendarRangeHours: calendarRangeHours,
|
||||
},
|
||||
position: {
|
||||
...widget.position,
|
||||
x: widget.position?.x || 0,
|
||||
y: widget.position?.y || 0,
|
||||
w: widgetWidth,
|
||||
h: widgetHeight,
|
||||
},
|
||||
})
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (needsDevice && (!selectedDeviceId)) {
|
||||
alert('Please select a device')
|
||||
return
|
||||
}
|
||||
|
||||
if (needsMetrics && selectedMetrics.length === 0) {
|
||||
alert('Please select at least one metric')
|
||||
return
|
||||
}
|
||||
|
||||
onSave(widget.id, {
|
||||
title,
|
||||
metricIds: selectedMetrics,
|
||||
deviceIds: needsDevice ? [selectedDeviceId] : [],
|
||||
metricIds: needsMetrics ? selectedMetrics : [],
|
||||
timeframe: {
|
||||
hours: timeframeHours,
|
||||
},
|
||||
@@ -102,6 +198,27 @@ export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: Edi
|
||||
<div className="badge badge-lg badge-primary">{widget.type}</div>
|
||||
</div>
|
||||
|
||||
{/* Device Selection */}
|
||||
{!['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type) && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Device</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={selectedDeviceId}
|
||||
onChange={(e) => setSelectedDeviceId(e.target.value)}
|
||||
>
|
||||
<option value="">Select a device</option>
|
||||
{devices.map((device) => (
|
||||
<option key={device.id} value={device.id}>
|
||||
{device.name} ({device.location || 'No location'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
@@ -117,6 +234,7 @@ export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: Edi
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
{!['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type) && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Select Metric(s)</span>
|
||||
@@ -131,7 +249,11 @@ export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: Edi
|
||||
</div>
|
||||
)}
|
||||
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
|
||||
{availableMetrics.length === 0 ? (
|
||||
{!selectedDeviceId ? (
|
||||
<div className="text-center text-base-content/60 py-4">
|
||||
Please select a device first
|
||||
</div>
|
||||
) : availableMetrics.length === 0 ? (
|
||||
<div className="text-center text-base-content/60 py-4">
|
||||
Loading metrics...
|
||||
</div>
|
||||
@@ -151,8 +273,10 @@ export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: Edi
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Range */}
|
||||
{!['calendar', 'daily-briefing'].includes(widget.type) && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Time Range</span>
|
||||
@@ -169,6 +293,157 @@ export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: Edi
|
||||
<option value={720}>Last 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{widget.type === 'calendar' && (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">iCal URL</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showCalendarUrl ? 'text' : 'password'}
|
||||
className="input input-bordered flex-1"
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={calendarUrl}
|
||||
onChange={(e) => setCalendarUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setShowCalendarUrl((prev) => !prev)}
|
||||
>
|
||||
{showCalendarUrl ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Agenda Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={calendarRangeHours}
|
||||
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={24}>Next 24 hours</option>
|
||||
<option value={72}>Next 3 days</option>
|
||||
<option value={168}>Next 7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{widget.type === 'daily-briefing' && (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Briefing Type</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'schedule' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('schedule')}
|
||||
>
|
||||
📅 Schedule
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'environment' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('environment')}
|
||||
>
|
||||
🌡️ Environment
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'full' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('full')}
|
||||
>
|
||||
✨ Full
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">City</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder="Enter city name (e.g., Skopje)"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(briefingType === 'schedule' || briefingType === 'full') && (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Calendar URL (Optional)</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showCalendarUrl ? 'text' : 'password'}
|
||||
className="input input-bordered flex-1"
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={calendarUrl}
|
||||
onChange={(e) => setCalendarUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setShowCalendarUrl((prev) => !prev)}
|
||||
>
|
||||
{showCalendarUrl ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{calendarUrl && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Calendar Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={calendarRangeHours}
|
||||
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={24}>Next 24 hours</option>
|
||||
<option value={72}>Next 3 days</option>
|
||||
<option value={168}>Next 7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{devices.length > 0 && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Health Device (Optional)</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={selectedDeviceId || ''}
|
||||
onChange={(e) => setSelectedDeviceId(e.target.value)}
|
||||
>
|
||||
<option value="">No health device</option>
|
||||
{devices.map((device) => (
|
||||
<option key={device.id} value={device.id}>
|
||||
{device.name} ({device.location || 'No location'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Size */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -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 (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
@@ -99,6 +177,222 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
||||
|
||||
{analysis && showAnalysis && !isLoading && (
|
||||
<div className="space-y-4 mt-4">
|
||||
{/* Structured Display for Trend Summary */}
|
||||
{isTrendSummary && parsedAnalysis && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`badge badge-lg ${getStatusColor(parsedAnalysis.status)}`}>
|
||||
{parsedAnalysis.status.toUpperCase()}
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50">
|
||||
{analysis.data_points_analyzed} data points
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-bold">Summary</div>
|
||||
<div className="text-sm">{parsedAnalysis.summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comfort Score */}
|
||||
{parsedAnalysis.comfort_score && (
|
||||
<div className="card bg-base-200">
|
||||
<div className="card-body p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="radial-progress text-primary" style={{ "--value": parsedAnalysis.comfort_score.rating } as React.CSSProperties}>
|
||||
{parsedAnalysis.comfort_score.rating}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">Comfort Score</div>
|
||||
<div className="text-sm text-base-content/70">{parsedAnalysis.comfort_score.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trends */}
|
||||
{parsedAnalysis.trends && parsedAnalysis.trends.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Trends</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.trends.map((trend: TrendSummary['trends'][0], i: number) => (
|
||||
<div key={i} className="card bg-base-200">
|
||||
<div className="card-body p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">{getDirectionIcon(trend.direction)}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-sm">{trend.metric}</div>
|
||||
<div className="text-xs text-base-content/70">{trend.description}</div>
|
||||
</div>
|
||||
<div className="badge badge-sm">{trend.direction}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Patterns */}
|
||||
{parsedAnalysis.patterns && parsedAnalysis.patterns.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Patterns Detected</h4>
|
||||
<ul className="space-y-1">
|
||||
{parsedAnalysis.patterns.map((pattern: string, i: number) => (
|
||||
<li key={i} className="text-sm flex items-start gap-2">
|
||||
<span className="text-primary mt-0.5">▸</span>
|
||||
<span>{pattern}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{parsedAnalysis.recommendations && parsedAnalysis.recommendations.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Recommendations</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.recommendations.map((rec: string, i: number) => (
|
||||
<div key={i} className="alert alert-success alert-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm">{rec}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forecast */}
|
||||
{parsedAnalysis.forecast && (
|
||||
<div className="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-bold text-sm">Forecast</div>
|
||||
<div className="text-xs">{parsedAnalysis.forecast}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Structured Display for Anomaly Detection */}
|
||||
{isAnomalyDetection && parsedAnalysis && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`badge badge-lg ${getStatusColor(parsedAnalysis.status)}`}>
|
||||
{parsedAnalysis.status.toUpperCase()}
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50">
|
||||
{analysis.data_points_analyzed} data points
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className={`alert ${parsedAnalysis.status === 'critical' ? 'alert-error' : parsedAnalysis.status === 'warning' ? 'alert-warning' : 'alert-success'}`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-bold">Summary</div>
|
||||
<div className="text-sm">{parsedAnalysis.summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anomalies */}
|
||||
{parsedAnalysis.anomalies && parsedAnalysis.anomalies.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Anomalies Detected</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.anomalies.map((anomaly: AnomalyDetection['anomalies'][0], i: number) => (
|
||||
<div key={i} className="card bg-base-200 border-l-4 border-error">
|
||||
<div className="card-body p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm">{anomaly.metric}</span>
|
||||
<span className={`badge badge-sm ${getSeverityColor(anomaly.severity)}`}>
|
||||
{anomaly.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-base-content/70 mb-1">{anomaly.description}</div>
|
||||
<div className="text-xs">
|
||||
<span className="text-error font-semibold">Current: {anomaly.value}</span>
|
||||
{' • '}
|
||||
<span className="text-base-content/60">Expected: {anomaly.expected}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Impacts */}
|
||||
{parsedAnalysis.impacts && parsedAnalysis.impacts.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Potential Impacts</h4>
|
||||
<ul className="space-y-1">
|
||||
{parsedAnalysis.impacts.map((impact: string, i: number) => (
|
||||
<li key={i} className="text-sm flex items-start gap-2">
|
||||
<span className="text-warning mt-0.5">▸</span>
|
||||
<span>{impact}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{parsedAnalysis.actions && parsedAnalysis.actions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Recommended Actions</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.actions.map((action: string, i: number) => (
|
||||
<div key={i} className="alert alert-warning alert-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="text-sm">{action}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Root Causes */}
|
||||
{parsedAnalysis.root_causes && parsedAnalysis.root_causes.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Possible Root Causes</h4>
|
||||
<ul className="space-y-1">
|
||||
{parsedAnalysis.root_causes.map((cause: string, i: number) => (
|
||||
<li key={i} className="text-sm flex items-start gap-2">
|
||||
<span className="text-info mt-0.5">▸</span>
|
||||
<span>{cause}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fallback: Raw Text Display */}
|
||||
{!parsedAnalysis && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="badge badge-primary badge-lg">
|
||||
{promptType === 'trend_summary' ? 'Trend Analysis' : 'Anomaly Detection'}
|
||||
@@ -113,6 +407,9 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
||||
{analysis.analysis}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
|
||||
308
frontend/src/components/widgets/CalendarWidget.tsx
Normal file
308
frontend/src/components/widgets/CalendarWidget.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { AxiosError } from 'axios'
|
||||
import ICAL from 'ical.js'
|
||||
import { calendarApi } from '../../api'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import './widget-styles.css'
|
||||
|
||||
const REFRESH_INTERVAL_MS = 5 * 60 * 1000
|
||||
const MAX_EVENTS = 25
|
||||
|
||||
interface CalendarWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
interface AgendaEvent {
|
||||
id: string
|
||||
summary: string
|
||||
start: Date
|
||||
end: Date
|
||||
location?: string | null
|
||||
description?: string | null
|
||||
isAllDay: boolean
|
||||
}
|
||||
|
||||
const dayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
function formatDayLabel(date: Date) {
|
||||
const today = new Date()
|
||||
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const diffMs = startOfDate.getTime() - startOfToday.getTime()
|
||||
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
|
||||
|
||||
if (Math.abs(diffDays) <= 1) {
|
||||
return relativeFormatter.format(diffDays, 'day')
|
||||
}
|
||||
|
||||
return dayFormatter.format(date)
|
||||
}
|
||||
|
||||
function formatEventRange(event: AgendaEvent) {
|
||||
const sameDay = event.start.toDateString() === event.end.toDateString()
|
||||
const dayLabel = formatDayLabel(event.start)
|
||||
|
||||
if (event.isAllDay) {
|
||||
return `${dayLabel} - All day`
|
||||
}
|
||||
|
||||
if (sameDay) {
|
||||
return `${dayLabel} - ${timeFormatter.format(event.start)} to ${timeFormatter.format(event.end)}`
|
||||
}
|
||||
|
||||
return `${dayLabel} ${timeFormatter.format(event.start)} -> ${formatDayLabel(event.end)} ${timeFormatter.format(event.end)}`
|
||||
}
|
||||
|
||||
function createAgendaEvents(
|
||||
component: ICAL.Component,
|
||||
windowStart: Date,
|
||||
windowEnd: Date,
|
||||
): AgendaEvent[] {
|
||||
const event = new ICAL.Event(component)
|
||||
const results: AgendaEvent[] = []
|
||||
const eventTemplate = {
|
||||
summary: event.summary || 'Untitled event',
|
||||
location: event.location || null,
|
||||
description: event.description || null,
|
||||
}
|
||||
|
||||
const addOccurrence = (start: ICAL.Time, end?: ICAL.Time | null) => {
|
||||
const jsStart = start.toJSDate()
|
||||
const jsEnd = (end || start).toJSDate()
|
||||
|
||||
if (jsEnd < windowStart || jsStart > windowEnd) {
|
||||
return
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: `${event.uid || event.summary}-${jsStart.toISOString()}`,
|
||||
summary: eventTemplate.summary,
|
||||
location: eventTemplate.location,
|
||||
description: eventTemplate.description,
|
||||
start: jsStart,
|
||||
end: jsEnd,
|
||||
isAllDay: start.isDate,
|
||||
})
|
||||
}
|
||||
|
||||
const overlapWindowStart = new Date(windowStart.getTime() - 24 * 60 * 60 * 1000)
|
||||
const iteratorStart = ICAL.Time.fromJSDate(overlapWindowStart)
|
||||
|
||||
if (event.isRecurring()) {
|
||||
const iterator = event.iterator(iteratorStart)
|
||||
let next = iterator.next()
|
||||
while (next) {
|
||||
const occurrence = event.getOccurrenceDetails(next)
|
||||
addOccurrence(occurrence.startDate, occurrence.endDate)
|
||||
const jsStart = occurrence.startDate.toJSDate()
|
||||
if (jsStart > windowEnd) {
|
||||
break
|
||||
}
|
||||
next = iterator.next()
|
||||
}
|
||||
} else {
|
||||
addOccurrence(event.startDate, event.endDate)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export default function CalendarWidget({ config }: CalendarWidgetProps) {
|
||||
const calendarConfig = config.calendar
|
||||
const [events, setEvents] = useState<AgendaEvent[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
const fetchEvents = useCallback(async () => {
|
||||
if (!calendarConfig?.icalUrl) {
|
||||
setEvents([])
|
||||
setError('Calendar URL is missing')
|
||||
return
|
||||
}
|
||||
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const requestId = requestIdRef.current + 1
|
||||
requestIdRef.current = requestId
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await calendarApi.fetch(calendarConfig.icalUrl, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const raw = response.data?.calendar_data
|
||||
if (!raw) {
|
||||
throw new Error('Calendar feed was empty')
|
||||
}
|
||||
const parsed = ICAL.parse(raw)
|
||||
const comp = new ICAL.Component(parsed)
|
||||
const vevents = comp.getAllSubcomponents('vevent') || []
|
||||
|
||||
const now = new Date()
|
||||
const windowStart = new Date(now.getTime() - 30 * 60 * 1000) // keep events that started recently
|
||||
const windowEnd = new Date(now.getTime() + (calendarConfig.timeRangeHours || 72) * 60 * 60 * 1000)
|
||||
|
||||
const agendaEvents = vevents
|
||||
.flatMap((vevent) => createAgendaEvents(vevent, windowStart, windowEnd))
|
||||
.sort((a, b) => a.start.getTime() - b.start.getTime())
|
||||
.slice(0, MAX_EVENTS)
|
||||
|
||||
setEvents(agendaEvents)
|
||||
setLastUpdated(new Date())
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const axiosError = err as AxiosError<{ error?: string; detail?: string }>
|
||||
const message = axiosError?.response?.data?.error
|
||||
|| axiosError?.response?.data?.detail
|
||||
|| axiosError?.message
|
||||
|| 'Failed to load calendar'
|
||||
setError(message)
|
||||
setEvents([])
|
||||
} finally {
|
||||
if (!controller.signal.aborted && requestId === requestIdRef.current) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [calendarConfig?.icalUrl, calendarConfig?.timeRangeHours])
|
||||
|
||||
useEffect(() => {
|
||||
if (!calendarConfig?.icalUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
fetchEvents()
|
||||
const interval = setInterval(fetchEvents, REFRESH_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
abortRef.current?.abort()
|
||||
}
|
||||
}, [calendarConfig?.icalUrl, fetchEvents])
|
||||
|
||||
const handleManualRefresh = () => {
|
||||
fetchEvents()
|
||||
}
|
||||
|
||||
if (!calendarConfig) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body p-4 text-sm">
|
||||
<h2 className="card-title text-sm mb-2">{config.title || 'Calendar'}</h2>
|
||||
<p className="opacity-70">
|
||||
Configure an iCal URL to see your agenda.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rangeLabel = `Next ${calendarConfig.timeRangeHours || 72}h`
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body p-3 h-full flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide opacity-60">Agenda</p>
|
||||
<h2 className="card-title text-sm leading-tight">{config.title || 'Calendar'}</h2>
|
||||
<p className="text-xs opacity-60">{rangeLabel}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-outline"
|
||||
onClick={handleManualRefresh}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
{lastUpdated && (
|
||||
<span className="text-[10px] opacity-60">
|
||||
Updated {timeFormatter.format(lastUpdated)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error text-xs">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && events.length === 0 && !isLoading && (
|
||||
<div className="flex-1 flex items-center justify-center text-sm opacity-60 text-center">
|
||||
No upcoming events in this window.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && events.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<span className="loading loading-spinner"></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{events.length > 0 && (
|
||||
<ul className="flex-1 overflow-y-auto divide-y divide-base-200">
|
||||
{events.map((event) => (
|
||||
<li key={event.id} className="py-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">
|
||||
{event.summary || 'Untitled event'}
|
||||
</p>
|
||||
<p className="text-xs opacity-70">
|
||||
{formatEventRange(event)}
|
||||
</p>
|
||||
{event.location && (
|
||||
<p className="text-[11px] opacity-70 mt-1 flex items-center gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 11c1.38 0 2.5-1.12 2.5-2.5S13.38 6 12 6s-2.5 1.12-2.5 2.5S10.62 11 12 11zm0 0c-4 0-5 4-5 4v.5a2.5 2.5 0 002.5 2.5h5a2.5 2.5 0 002.5-2.5V15s-1-4-5-4z"
|
||||
/>
|
||||
</svg>
|
||||
{event.location}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-right whitespace-nowrap">
|
||||
{event.isAllDay ? 'All day' : timeFormatter.format(event.start)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.7}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ThermometerIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M14 14.5V5a2 2 0 00-4 0v9.5a3.5 3.5 0 104 0z" />
|
||||
<line x1="12" y1="8" x2="12" y2="11" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const DropletIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M12 3.5s-4 5-4 8.5a4 4 0 108 0c0-3.5-4-8.5-4-8.5z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const AirQualityIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M4 12h9a3 3 0 10-3-3" />
|
||||
<path d="M6 17h8a3 3 0 11-3 3" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const AcousticIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M5 9v6h3l4 4V5l-4 4H5z" />
|
||||
<path d="M16 9a4 4 0 010 6" />
|
||||
<path d="M18 7a6 6 0 010 10" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const LightIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M12 3a5 5 0 00-3 9v3h6v-3a5 5 0 00-3-9z" />
|
||||
<path d="M10 18h4" />
|
||||
<path d="M10 21h4" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
interface ComfortIndexWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
@@ -85,7 +138,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">🌡️</span>
|
||||
<ThermometerIcon className="w-4 h-4" />
|
||||
<span className="truncate">Temperature</span>
|
||||
</div>
|
||||
<span className={`font-bold ${getScoreColor(data.components.temperature)}`}>
|
||||
@@ -95,7 +148,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">💧</span>
|
||||
<DropletIcon className="w-4 h-4" />
|
||||
<span className="truncate">Humidity</span>
|
||||
</div>
|
||||
<span className={`font-bold ${getScoreColor(data.components.humidity)}`}>
|
||||
@@ -105,7 +158,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">🌬️</span>
|
||||
<AirQualityIcon className="w-4 h-4" />
|
||||
<span className="truncate">Air Quality</span>
|
||||
</div>
|
||||
<span className={`font-bold ${getScoreColor(data.components.air_quality)}`}>
|
||||
@@ -115,7 +168,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">🔊</span>
|
||||
<AcousticIcon className="w-4 h-4" />
|
||||
<span className="truncate">Acoustic</span>
|
||||
</div>
|
||||
<span className={`font-bold ${getScoreColor(data.components.acoustic)}`}>
|
||||
@@ -125,7 +178,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-base-200 rounded col-span-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">💡</span>
|
||||
<LightIcon className="w-4 h-4" />
|
||||
<span className="truncate">Lighting</span>
|
||||
</div>
|
||||
<span className={`font-bold ${getScoreColor(data.components.light)}`}>
|
||||
@@ -137,7 +190,10 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
||||
{/* Suggestions */}
|
||||
{data.suggestions.length > 0 && (
|
||||
<div className="mt-auto">
|
||||
<div className="text-xs font-semibold mb-1">💡 Suggestions</div>
|
||||
<div className="flex items-center gap-1 text-xs font-semibold mb-1">
|
||||
<LightIcon className="w-3.5 h-3.5" />
|
||||
<span>Suggestions</span>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-20 overflow-y-auto">
|
||||
{data.suggestions.map((suggestion, i) => (
|
||||
<div key={i} className="text-xs bg-warning/10 p-1 rounded border-l-2 border-warning">
|
||||
|
||||
266
frontend/src/components/widgets/DailyBriefingWidget.tsx
Normal file
266
frontend/src/components/widgets/DailyBriefingWidget.tsx
Normal file
@@ -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<string, number> | null
|
||||
outdoor: Record<string, number | string | null> | null
|
||||
health: Record<string, number> | null
|
||||
calendar_event_count: number
|
||||
}
|
||||
}
|
||||
|
||||
const CalendarIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BuildingIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ClipboardIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const LightbulbIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const StatusIcon = ({ status }: { status: string }) => {
|
||||
if (status.includes('good') || status.includes('great') || status.includes('optimal')) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (status.includes('warning') || status.includes('moderate') || status.includes('attention')) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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<BriefingType>(
|
||||
(briefingConfig?.briefingType as BriefingType) || 'full'
|
||||
)
|
||||
const [briefingData, setBriefingData] = useState<BriefingData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body p-4">
|
||||
<h2 className="card-title text-sm">{config.title || 'Daily Briefing'}</h2>
|
||||
<p className="text-sm opacity-70">
|
||||
Configure a city to generate briefings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full flex flex-col">
|
||||
<div className="card-body p-3 flex-1 flex flex-col gap-2 min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide opacity-60 flex items-center gap-1">
|
||||
<CurrentIcon /> {getBriefingTypeInfo(briefingType).label} Briefing
|
||||
</p>
|
||||
<h2 className="card-title text-sm leading-tight">{config.title || 'Daily Briefing'}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Briefing Type Selector */}
|
||||
<div className="flex gap-1">
|
||||
{BRIEFING_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
className={`btn btn-xs flex-1 gap-1 ${
|
||||
briefingType === type.value ? 'btn-primary' : 'btn-outline'
|
||||
}`}
|
||||
onClick={() => setBriefingType(type.value)}
|
||||
title={type.description}
|
||||
>
|
||||
<type.Icon /> {type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Generate Button or Content */}
|
||||
{!briefingData && !isLoading && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3">
|
||||
<p className="text-sm opacity-60 text-center">
|
||||
Get AI-powered insights for your day
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={generateBriefing}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Generate Briefing
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
<p className="text-sm opacity-60">Analyzing your environment...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !isLoading && (
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div className="alert alert-error text-xs">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={generateBriefing}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Briefing Content */}
|
||||
{briefingData && !isLoading && (
|
||||
<div className="flex-1 flex flex-col gap-2 overflow-y-auto">
|
||||
{/* Status Line */}
|
||||
<div className="bg-base-200 rounded-lg p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon status={briefingData.status_line.toLowerCase()} />
|
||||
<p className="text-sm font-medium">{briefingData.status_line}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights */}
|
||||
{briefingData.insights.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{briefingData.insights.map((insight, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-primary mt-0.5">•</span>
|
||||
<span className="opacity-90">{insight}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{briefingData.recommendations.length > 0 && (
|
||||
<div className="bg-primary/10 rounded-lg p-2 mt-auto">
|
||||
<div className="flex items-start gap-2">
|
||||
<LightbulbIcon />
|
||||
<div className="space-y-1">
|
||||
{briefingData.recommendations.map((rec, i) => (
|
||||
<p key={i} className="text-sm opacity-90">{rec}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with timestamp and refresh */}
|
||||
<div className="flex items-center justify-between pt-1 border-t border-base-200 mt-auto">
|
||||
<span className="text-[10px] opacity-50">
|
||||
Generated {timeFormatter.format(new Date(briefingData.generated_at))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-ghost"
|
||||
onClick={generateBriefing}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
178
frontend/src/components/widgets/HealthStatsWidget.tsx
Normal file
178
frontend/src/components/widgets/HealthStatsWidget.tsx
Normal file
@@ -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<string, unknown>)?.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 (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex items-center justify-center">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex items-center justify-center">
|
||||
<div className="alert alert-error text-xs">
|
||||
<span>
|
||||
{error instanceof Error ? error.message : 'Failed to load health data'}
|
||||
</span>
|
||||
</div>
|
||||
{!deviceId && (
|
||||
<p className="text-xs text-base-content/60 mt-2">Select a health device</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const StepIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const FireIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const HeartIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const WarningIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Steps',
|
||||
value: data.health_metrics.steps,
|
||||
unit: '',
|
||||
icon: <StepIcon />,
|
||||
color: 'text-primary'
|
||||
},
|
||||
{
|
||||
label: 'Calories',
|
||||
value: data.health_metrics.active_calories,
|
||||
unit: 'kcal',
|
||||
icon: <FireIcon />,
|
||||
color: 'text-secondary'
|
||||
},
|
||||
{
|
||||
label: 'Heart Rate',
|
||||
value: data.health_metrics.heart_rate,
|
||||
unit: 'bpm',
|
||||
icon: <HeartIcon />,
|
||||
color: 'text-error'
|
||||
},
|
||||
]
|
||||
|
||||
// Find insights for each stat
|
||||
const getInsightForMetric = (metric: string) => {
|
||||
return data.insights.find(i => i.metric === metric)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full flex flex-col">
|
||||
<div className="card-body p-3 flex-1 min-h-0">
|
||||
<h2 className="card-title text-sm mb-3">{config.title}</h2>
|
||||
|
||||
{/* Health Stats */}
|
||||
<div className="space-y-3">
|
||||
{stats.map((stat, i) => {
|
||||
const insight = getInsightForMetric(stat.label)
|
||||
const hasValue = stat.value !== null && stat.value !== undefined
|
||||
|
||||
return (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={stat.color}>{stat.icon}</span>
|
||||
<span className="text-sm opacity-70">{stat.label}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-xl font-bold ${stat.color}`}>
|
||||
{hasValue ? Math.round(Number(stat.value)).toLocaleString() : '—'}
|
||||
</span>
|
||||
{hasValue && <span className="text-xs opacity-50">{stat.unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context/Correlation */}
|
||||
{insight && (
|
||||
<div className="text-xs space-y-0.5 ml-7">
|
||||
{insight.context && (
|
||||
<div className="opacity-70">{insight.context}</div>
|
||||
)}
|
||||
{insight.correlation && (
|
||||
<div className="text-warning opacity-80 flex items-center gap-1">
|
||||
<WarningIcon />
|
||||
{insight.correlation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
{data.recommendations.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-base-300">
|
||||
<div className="text-xs">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="text-success"><CheckIcon /></span>
|
||||
<span className="opacity-80">{data.recommendations[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, Record<string, number | string>>()
|
||||
// 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<number, Record<string, number | string>>()
|
||||
|
||||
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 || [
|
||||
// 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) => (
|
||||
<Line
|
||||
key={metric}
|
||||
type="monotone"
|
||||
dataKey={metric}
|
||||
stroke={colors[index % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={false} // Disable dots for better performance
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={true}
|
||||
name={formatMetricName(metric)}
|
||||
isAnimationActive={false} // Disable animations for better performance
|
||||
/>
|
||||
)),
|
||||
[metricIds, colors]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -190,7 +241,11 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
||||
{config.title || metricIds.map(formatMetricName).join(' & ')}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={visualization?.height || 280}>
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 50 }}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 5, right: 20, left: 0, bottom: 50 }}
|
||||
syncId="dashboard-charts" // Sync charts for better performance
|
||||
>
|
||||
{visualization?.showGrid !== false && (
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
)}
|
||||
@@ -200,6 +255,7 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={50}
|
||||
interval="preserveStartEnd" // Reduce number of ticks
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
@@ -216,22 +272,13 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
||||
formatter={(value: number) => [value.toFixed(2)]}
|
||||
/>
|
||||
{visualization?.showLegend !== false && <Legend />}
|
||||
{metricIds.map((metric, index) => (
|
||||
<Line
|
||||
key={metric}
|
||||
type="monotone"
|
||||
dataKey={metric}
|
||||
stroke={colors[index % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 2, strokeWidth: 0 }}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={true}
|
||||
name={formatMetricName(metric)}
|
||||
/>
|
||||
))}
|
||||
{lines}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Memoize the component to prevent unnecessary re-renders
|
||||
export default memo(LineChartWidget)
|
||||
|
||||
162
frontend/src/components/widgets/RunSuitabilityWidget.tsx
Normal file
162
frontend/src/components/widgets/RunSuitabilityWidget.tsx
Normal file
@@ -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 = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const StepIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
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<string, unknown>)?.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 (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex items-center justify-center">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex flex-col items-center justify-center text-center gap-2">
|
||||
<div className="alert alert-error text-xs">
|
||||
<span>
|
||||
{error instanceof Error ? error.message : 'No data available'}
|
||||
</span>
|
||||
</div>
|
||||
{(!deviceId || !widgetCity) && (
|
||||
<p className="text-xs text-base-content/60">
|
||||
{!deviceId && 'Select a health device'}
|
||||
{!deviceId && !widgetCity && ' and '}
|
||||
{!widgetCity && 'Select a city'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="widget-card card bg-base-100 h-full flex flex-col">
|
||||
<div className="card-body p-3 flex flex-col gap-2 flex-1 min-h-0">
|
||||
<h2 className="card-title text-sm mb-2">{config.title}</h2>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="flex flex-col items-center justify-center text-center gap-2">
|
||||
<div className={`w-20 h-20 rounded-full flex items-center justify-center border-4 ${getStatusColor(data.status)} ${getBgColor(data.status)}`}>
|
||||
<span className={`text-2xl font-black ${getStatusColor(data.status)}`}>
|
||||
{data.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Primary Reason */}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-sm">{data.primary_reason}</p>
|
||||
|
||||
{/* Score Breakdown */}
|
||||
<div className="flex gap-2 text-xs text-base-content/60 justify-center">
|
||||
<span>Weather: {data.scores.weather.toFixed(0)}</span>
|
||||
<span>•</span>
|
||||
<span>Air: {data.scores.air_quality.toFixed(0)}</span>
|
||||
<span>•</span>
|
||||
<span>Health: {data.scores.health.toFixed(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Insights */}
|
||||
{data.detailed_insights.length > 0 && (
|
||||
<div className="space-y-1 mt-1">
|
||||
<div className="text-xs font-semibold opacity-70">Conditions:</div>
|
||||
<ul className="text-xs space-y-0.5">
|
||||
{data.detailed_insights.slice(0, 2).map((insight, i) => (
|
||||
<li key={i} className="opacity-80">• {insight}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Recommendations */}
|
||||
{data.time_recommendations.length > 0 && (
|
||||
<div className="space-y-1 mt-1">
|
||||
<div className="text-xs font-semibold opacity-70">Best Time:</div>
|
||||
<div className="text-xs opacity-80">• {data.time_recommendations[0]}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{data.suggestions.length > 0 && (
|
||||
<div className="mt-1 pt-1 border-t border-base-300">
|
||||
<div className="text-xs">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="text-success"><CheckIcon /></span>
|
||||
<span className="opacity-80">{data.suggestions[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Health Stats */}
|
||||
<div className="text-xs text-base-content/60 flex gap-2 justify-center pt-1 border-t border-base-300 flex-shrink-0 mt-auto">
|
||||
<span className="flex items-center gap-1">
|
||||
<StepIcon />
|
||||
{data.health_data.steps.toLocaleString()} steps
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.7}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SunIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<line x1="12" y1="2" x2="12" y2="5" />
|
||||
<line x1="12" y1="19" x2="12" y2="22" />
|
||||
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34" />
|
||||
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78" />
|
||||
<line x1="2" y1="12" x2="5" y2="12" />
|
||||
<line x1="19" y1="12" x2="22" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66" />
|
||||
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const CloudIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M5 15a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 15H5z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const PartlyCloudyIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<circle cx="8" cy="8" r="3" />
|
||||
<path d="M5 17a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 17H5z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const FogIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M4 10h11a3 3 0 000-6 4.5 4.5 0 00-8.91 1" />
|
||||
<line x1="3" y1="15" x2="17" y2="15" />
|
||||
<line x1="5" y1="19" x2="19" y2="19" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const RainIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M5 15a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 15H5z" />
|
||||
<line x1="8" y1="17" x2="8" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="22" />
|
||||
<line x1="16" y1="17" x2="16" y2="21" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const SnowIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M5 14a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 14H5z" />
|
||||
<line x1="11" y1="16" x2="11" y2="22" />
|
||||
<line x1="8.5" y1="18" x2="13.5" y2="20" />
|
||||
<line x1="8.5" y1="20" x2="13.5" y2="18" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const ThunderIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M5 15a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 15H5z" />
|
||||
<polyline points="12 16 10 20 14 20 12 24" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const ThermometerIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M14 14.5V5a2 2 0 00-4 0v9.5a3.5 3.5 0 104 0z" />
|
||||
<line x1="12" y1="8" x2="12" y2="11" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const DropletIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M12 3.5s-4 5-4 8.5a4 4 0 108 0c0-3.5-4-8.5-4-8.5z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const WindIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M3 12h9a3 3 0 10-3-3" />
|
||||
<path d="M5 18h11a3 3 0 11-3 3" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const CloudCoverIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M6 17a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0119 17H6z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const RainDropIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M7 14a5 5 0 0010 0c0-4-5-9-5-9s-5 5-5 9z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
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 <SunIcon className="w-16 h-16 text-warning" />
|
||||
if (code === 2) return <PartlyCloudyIcon className="w-16 h-16 text-primary" />
|
||||
if (code === 3) return <CloudIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 45 && code <= 48) return <FogIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 51 && code <= 55) return <RainIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 61 && code <= 65) return <RainIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 71 && code <= 77) return <SnowIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 80 && code <= 82) return <RainIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 85 && code <= 86) return <SnowIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 95) return <ThunderIcon className="w-16 h-16 text-primary" />
|
||||
return <ThermometerIcon className="w-16 h-16 text-primary" />
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -66,7 +176,7 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
||||
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
{/* Weather Icon */}
|
||||
<div className="text-5xl mb-1">{getWeatherIcon(weather.weather_code)}</div>
|
||||
<div className="mb-1 text-primary">{getWeatherIcon(weather.weather_code)}</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div className="text-3xl font-bold">{weather.temperature.toFixed(1)}°C</div>
|
||||
@@ -82,20 +192,20 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
||||
{/* Additional Info */}
|
||||
<div className="grid grid-cols-2 gap-2 mt-2 w-full text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="opacity-60">💧</span>
|
||||
<DropletIcon className="w-4 h-4 opacity-70" />
|
||||
<span>{weather.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="opacity-60">💨</span>
|
||||
<WindIcon className="w-4 h-4 opacity-70" />
|
||||
<span>{weather.wind_speed.toFixed(1)} km/h</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="opacity-60">☁️</span>
|
||||
<CloudCoverIcon className="w-4 h-4 opacity-70" />
|
||||
<span>{weather.cloud_cover}%</span>
|
||||
</div>
|
||||
{weather.precipitation > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="opacity-60">🌧️</span>
|
||||
<RainDropIcon className="w-4 h-4 opacity-70" />
|
||||
<span>{weather.precipitation} mm</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="relative group h-full">
|
||||
{/* Drag handle and actions */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-2 opacity-0 group-hover:opacity-100 transition-opacity bg-gradient-to-b from-base-300/90 to-transparent">
|
||||
<div className="drag-handle cursor-move flex items-center gap-1 px-2 py-1 rounded bg-base-100/80 text-xs">
|
||||
<div className="relative group h-full w-full">
|
||||
<div className="absolute top-2 left-2 right-2 z-20 flex items-center justify-between opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||
<div className="drag-handle cursor-move flex items-center gap-1 px-2 py-1 rounded bg-base-100 shadow-md text-xs border border-base-300 pointer-events-auto">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
@@ -41,11 +65,15 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont
|
||||
</svg>
|
||||
Drag
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-1 pointer-events-auto">
|
||||
{onEdit && (
|
||||
<button
|
||||
className="btn btn-xs btn-circle btn-ghost bg-base-100/80"
|
||||
onClick={() => onEdit(config.id)}
|
||||
type="button"
|
||||
className="btn btn-xs btn-circle btn-ghost bg-base-100 shadow-md border border-base-300 hover:bg-base-200"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit(config.id)
|
||||
}}
|
||||
title="Edit widget"
|
||||
>
|
||||
<svg
|
||||
@@ -66,8 +94,12 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont
|
||||
)}
|
||||
{onRemove && (
|
||||
<button
|
||||
className="btn btn-xs btn-circle btn-ghost bg-base-100/80"
|
||||
onClick={() => onRemove(config.id)}
|
||||
type="button"
|
||||
className="btn btn-xs btn-circle btn-ghost bg-base-100 shadow-md border border-base-300 hover:bg-error hover:text-error-content"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove(config.id)
|
||||
}}
|
||||
title="Remove widget"
|
||||
>
|
||||
<svg
|
||||
@@ -89,10 +121,12 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actual widget */}
|
||||
<div className="h-full">
|
||||
{/* Allow overlay to float without reserving layout space */}
|
||||
<div className="w-full" ref={contentRef}>
|
||||
<WidgetComponent config={config} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(WidgetContainer)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<WidgetType, ComponentType<WidgetProps>> = {
|
||||
'air-quality': AirQualityWidget,
|
||||
'weather': WeatherWidget,
|
||||
'comfort-index': ComfortIndexWidget,
|
||||
'run-suitability': RunSuitabilityWidget,
|
||||
'health-stats': HealthStatsWidget,
|
||||
'calendar': CalendarWidget,
|
||||
'daily-briefing': DailyBriefingWidget,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<DashboardConfig>(() => {
|
||||
const [config, setConfig] = useState<DashboardConfig>(DEFAULT_CONFIG)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [layoutId, setLayoutId] = useState<number | null>(null)
|
||||
const isInitialLoadRef = useRef(true)
|
||||
|
||||
// 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) {
|
||||
return JSON.parse(stored) as DashboardConfig
|
||||
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
|
||||
}
|
||||
}
|
||||
return DEFAULT_CONFIG
|
||||
})
|
||||
|
||||
// Persist to localStorage whenever config changes
|
||||
useEffect(() => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
155
frontend/src/hooks/useGridstack.ts
Normal file
155
frontend/src/hooks/useGridstack.ts
Normal file
@@ -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<HTMLDivElement>(null)
|
||||
const gridInstanceRef = useRef<GridStack | null>(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<GridStackWidget>) => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
40
frontend/src/hooks/useWellnessState.tsx
Normal file
40
frontend/src/hooks/useWellnessState.tsx
Normal file
@@ -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<WellnessState | undefined>(undefined)
|
||||
|
||||
export function WellnessStateProvider({ children }: { children: ReactNode }) {
|
||||
const [healthDeviceId, setHealthDeviceId] = useState<string | null>(null)
|
||||
const [city, setCity] = useState<string>('Skopje')
|
||||
|
||||
return (
|
||||
<WellnessStateContext.Provider
|
||||
value={{
|
||||
healthDeviceId,
|
||||
city,
|
||||
setHealthDeviceId,
|
||||
setCity,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</WellnessStateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWellnessState() {
|
||||
const context = useContext(WellnessStateContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useWellnessState must be used within WellnessStateProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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<string | null>(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<HTMLDivElement>(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) => ({
|
||||
const layout = config.widgets.map((widget) => {
|
||||
const position = widget.position ?? { x: 0, y: Infinity, w: 1, h: 1 }
|
||||
return {
|
||||
i: widget.id,
|
||||
x: widget.position?.x || 0,
|
||||
y: widget.position?.y || 0,
|
||||
w: widget.position?.w || 1,
|
||||
h: widget.position?.h || 2,
|
||||
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: 4,
|
||||
}))
|
||||
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 (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
@@ -92,7 +152,30 @@ export default function Dashboard() {
|
||||
Customize your view with modular widgets
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={handleSaveDashboard}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
Save Dashboard
|
||||
</button>
|
||||
{saveStatus === 'success' && (
|
||||
<span className="text-success text-sm">Saved!</span>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<span className="text-error text-sm">Save failed</span>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={handleExport}
|
||||
@@ -152,8 +235,8 @@ export default function Dashboard() {
|
||||
<GridLayout
|
||||
className="layout"
|
||||
layout={layout}
|
||||
cols={4}
|
||||
rowHeight={150}
|
||||
cols={GRID_COLUMNS}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
width={gridWidth}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
draggableHandle=".drag-handle"
|
||||
@@ -161,7 +244,8 @@ export default function Dashboard() {
|
||||
preventCollision={false}
|
||||
isResizable={true}
|
||||
isDraggable={true}
|
||||
margin={[12, 12]}
|
||||
margin={GRID_MARGIN}
|
||||
containerPadding={[0, 0]}
|
||||
>
|
||||
{config.widgets.map((widget) => (
|
||||
<div key={widget.id} className="h-full">
|
||||
@@ -169,6 +253,7 @@ export default function Dashboard() {
|
||||
config={widget}
|
||||
onRemove={() => removeWidget(widget.id)}
|
||||
onEdit={() => setEditingWidget(widget.id)}
|
||||
onHeightChange={(height: number) => handleWidgetHeightChange(widget.id, height)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
2
infrastructure/.gitignore
vendored
2
infrastructure/.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
mosquitto/certs/
|
||||
mosquitto/data/
|
||||
mosquitto/logs/
|
||||
.env
|
||||
.env*
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from rest_framework import serializers
|
||||
from iotDashboard.models import Device, DeviceCertificate, Telemetry
|
||||
from iotDashboard.dashboard_models import DashboardLayout
|
||||
|
||||
|
||||
class DeviceCertificateSerializer(serializers.ModelSerializer):
|
||||
@@ -75,3 +76,13 @@ class DashboardOverviewSerializer(serializers.Serializer):
|
||||
certificates_expiring_soon = serializers.IntegerField()
|
||||
recent_telemetry = TelemetrySerializer(many=True)
|
||||
devices_with_metrics = DeviceMetricsSerializer(many=True)
|
||||
|
||||
|
||||
# Dashboard Layout Serializers
|
||||
class DashboardLayoutSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for dashboard layouts (single-user system)."""
|
||||
|
||||
class Meta:
|
||||
model = DashboardLayout
|
||||
fields = ['id', 'name', 'config', 'is_default', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import DeviceViewSet, TelemetryViewSet, DashboardViewSet, WeatherViewSet
|
||||
from .views import (
|
||||
DeviceViewSet, TelemetryViewSet, DashboardViewSet, WeatherViewSet,
|
||||
WellnessViewSet, DashboardLayoutViewSet, CalendarViewSet
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'devices', DeviceViewSet, basename='device')
|
||||
router.register(r'telemetry', TelemetryViewSet, basename='telemetry')
|
||||
router.register(r'dashboard', DashboardViewSet, basename='dashboard')
|
||||
router.register(r'weather', WeatherViewSet, basename='weather')
|
||||
router.register(r'wellness', WellnessViewSet, basename='wellness')
|
||||
router.register(r'dashboard-layouts', DashboardLayoutViewSet, basename='dashboard-layout')
|
||||
router.register(r'calendar', CalendarViewSet, basename='calendar')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"""DRF ViewSets for IoT Dashboard API."""
|
||||
|
||||
import requests
|
||||
from datetime import timedelta
|
||||
from datetime import timedelta, datetime
|
||||
from urllib.parse import urlparse
|
||||
from django.utils import timezone
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework import viewsets, status, permissions
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
from iotDashboard.models import Device, DeviceCertificate, Telemetry
|
||||
from iotDashboard.dashboard_models import DashboardLayout
|
||||
from iotDashboard.device_manager_client import (
|
||||
DeviceManagerClient,
|
||||
DeviceManagerAPIError
|
||||
@@ -20,11 +22,23 @@ from iotDashboard.comfort_index import (
|
||||
ComfortIndexCalculator,
|
||||
calculate_comfort_index_from_telemetry,
|
||||
)
|
||||
from iotDashboard.run_suitability import (
|
||||
RunSuitabilityCalculator,
|
||||
WeatherData,
|
||||
AirQualityData,
|
||||
HealthData,
|
||||
)
|
||||
from iotDashboard.health_insights import (
|
||||
HealthInsightsCalculator,
|
||||
HealthMetrics,
|
||||
EnvironmentalContext,
|
||||
)
|
||||
from .serializers import (
|
||||
DeviceSerializer,
|
||||
DeviceCreateSerializer,
|
||||
TelemetrySerializer,
|
||||
DashboardOverviewSerializer,
|
||||
DashboardLayoutSerializer,
|
||||
)
|
||||
|
||||
|
||||
@@ -352,7 +366,7 @@ class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
return Response({'metrics': list(metrics)})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
@action(detail=False, methods=['post'], url_path='analyze', url_name='analyze')
|
||||
def analyze(self, request):
|
||||
"""Analyze telemetry data using GPT service."""
|
||||
# Parse request parameters
|
||||
@@ -422,9 +436,9 @@ class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
custom_prompt=custom_prompt
|
||||
)
|
||||
return Response({
|
||||
'analysis': result.analysis,
|
||||
'prompt_type': result.prompt_type,
|
||||
'data_points_analyzed': result.data_points_analyzed
|
||||
'analysis': result['analysis'],
|
||||
'prompt_type': result['prompt_type'],
|
||||
'data_points_analyzed': result['data_points_analyzed']
|
||||
})
|
||||
|
||||
except gpt_service_client.GPTServiceError as e:
|
||||
@@ -576,3 +590,552 @@ class WeatherViewSet(viewsets.ViewSet):
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
)
|
||||
|
||||
|
||||
class CalendarViewSet(viewsets.ViewSet):
|
||||
""" ViewSet for Calendar"""
|
||||
|
||||
@action(detail=False,methods=['get'])
|
||||
def fetch(self,request):
|
||||
""" Fetch calendar events """
|
||||
calendar_url = request.query_params.get('calendar_url')
|
||||
if not calendar_url:
|
||||
return Response(
|
||||
{'error': 'calendar_url parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
parsed = urlparse(calendar_url)
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
return Response(
|
||||
{'error': 'Only http/https calendar URLs are supported'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
# Keep timeout small to avoid hanging the API worker
|
||||
calendar_response = requests.get(calendar_url, timeout=10)
|
||||
calendar_response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
return Response(
|
||||
{
|
||||
'error': 'Failed to fetch calendar feed',
|
||||
'details': str(exc),
|
||||
},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
text = calendar_response.text or ''
|
||||
if not text.strip():
|
||||
return Response(
|
||||
{'error': 'Calendar feed returned no data'},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
return Response({'calendar_data': text})
|
||||
|
||||
|
||||
class WellnessViewSet(viewsets.ViewSet):
|
||||
"""ViewSet for wellness analysis combining health and environmental data."""
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def run_suitability(self, request):
|
||||
"""
|
||||
Calculate run suitability combining weather, air quality, and health data.
|
||||
|
||||
Query params:
|
||||
- health_device_id: Device ID for health metrics (required)
|
||||
- city: City name for weather/air quality (required)
|
||||
- time_of_day: Optional time override (ISO format)
|
||||
"""
|
||||
health_device_id = request.query_params.get('health_device_id')
|
||||
city = request.query_params.get('city')
|
||||
time_of_day_str = request.query_params.get('time_of_day')
|
||||
|
||||
if not health_device_id:
|
||||
return Response(
|
||||
{'error': 'health_device_id parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if not city:
|
||||
return Response(
|
||||
{'error': 'city parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
# Get health device
|
||||
try:
|
||||
health_device = Device.objects.get(id=health_device_id)
|
||||
except Device.DoesNotExist:
|
||||
return Response(
|
||||
{'error': f'Health device {health_device_id} not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Fetch weather data
|
||||
try:
|
||||
weather_data = weather_client.get_weather_by_city(city)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Failed to fetch weather data: {str(e)}'},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
)
|
||||
|
||||
# Fetch air quality data
|
||||
try:
|
||||
raw_air_quality = weather_client.get_air_quality(city.lower())
|
||||
air_quality_data = weather_client.parse_air_quality_data(raw_air_quality, city.lower())
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Failed to fetch air quality data: {str(e)}'},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
)
|
||||
|
||||
# Get health metrics from telemetry
|
||||
health_metrics = {}
|
||||
health_metric_names = {
|
||||
'steps': ['steps', 'step_count'],
|
||||
'active_calories': ['active_calories', 'calories'],
|
||||
'heart_rate': ['heart_rate', 'heart_rate_bpm', 'hr'],
|
||||
'resting_heart_rate': ['resting_heart_rate', 'resting_hr'],
|
||||
}
|
||||
|
||||
for key, possible_names in health_metric_names.items():
|
||||
for metric_name in possible_names:
|
||||
latest = (
|
||||
Telemetry.objects
|
||||
.filter(device_id=health_device_id, metric=metric_name)
|
||||
.order_by('-time')
|
||||
.first()
|
||||
)
|
||||
if latest:
|
||||
health_metrics[key] = float(latest.value)
|
||||
break
|
||||
|
||||
# Get current time
|
||||
if time_of_day_str:
|
||||
try:
|
||||
current_time = datetime.fromisoformat(time_of_day_str.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
current_time = timezone.now()
|
||||
else:
|
||||
current_time = timezone.now()
|
||||
|
||||
# Prepare data for calculator
|
||||
weather = WeatherData(
|
||||
temperature=weather_data.get('temperature', 20),
|
||||
apparent_temperature=weather_data.get('apparent_temperature', 20),
|
||||
wind_speed=weather_data.get('wind_speed', 0),
|
||||
precipitation=weather_data.get('precipitation', 0),
|
||||
rain=weather_data.get('rain', 0),
|
||||
weather_code=weather_data.get('weather_code', 0),
|
||||
humidity=weather_data.get('humidity', 50),
|
||||
cloud_cover=weather_data.get('cloud_cover', 0),
|
||||
)
|
||||
|
||||
air_quality = AirQualityData(
|
||||
pm25=air_quality_data.get('measurements', {}).get('pm25', {}).get('average'),
|
||||
pm10=air_quality_data.get('measurements', {}).get('pm10', {}).get('average'),
|
||||
status=air_quality_data.get('status', 'Unknown'),
|
||||
)
|
||||
|
||||
health = HealthData(
|
||||
steps_today=health_metrics.get('steps', 0),
|
||||
active_calories=health_metrics.get('active_calories', 0),
|
||||
heart_rate=health_metrics.get('heart_rate'),
|
||||
resting_heart_rate=health_metrics.get('resting_heart_rate'),
|
||||
daily_goal_steps=10000, # Default goal
|
||||
)
|
||||
|
||||
# Calculate run suitability
|
||||
result = RunSuitabilityCalculator.calculate(
|
||||
weather=weather,
|
||||
air_quality=air_quality,
|
||||
health=health,
|
||||
current_time=current_time
|
||||
)
|
||||
|
||||
return Response({
|
||||
'status': result.status,
|
||||
'overall_score': result.overall_score,
|
||||
'scores': {
|
||||
'weather': result.weather_score,
|
||||
'air_quality': result.air_quality_score,
|
||||
'health': result.health_score,
|
||||
},
|
||||
'primary_reason': result.primary_reason,
|
||||
'detailed_insights': result.detailed_insights,
|
||||
'time_recommendations': result.time_recommendations,
|
||||
'suggestions': result.suggestions,
|
||||
'weather_data': {
|
||||
'temperature': weather.temperature,
|
||||
'wind_speed': weather.wind_speed,
|
||||
'precipitation': weather.precipitation,
|
||||
'description': weather_data.get('weather_description'),
|
||||
},
|
||||
'air_quality_data': {
|
||||
'pm25': air_quality.pm25,
|
||||
'pm10': air_quality.pm10,
|
||||
'status': air_quality.status,
|
||||
},
|
||||
'health_data': {
|
||||
'steps': health.steps_today,
|
||||
'active_calories': health.active_calories,
|
||||
'heart_rate': health.heart_rate,
|
||||
},
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Failed to calculate run suitability: {str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def health_insights(self, request):
|
||||
"""
|
||||
Get contextual health insights with environmental correlations.
|
||||
|
||||
Query params:
|
||||
- health_device_id: Device ID for health metrics (required)
|
||||
- city: City name for weather/air quality context (optional)
|
||||
"""
|
||||
health_device_id = request.query_params.get('health_device_id')
|
||||
city = request.query_params.get('city')
|
||||
|
||||
if not health_device_id:
|
||||
return Response(
|
||||
{'error': 'health_device_id parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
# Get health device
|
||||
try:
|
||||
health_device = Device.objects.get(id=health_device_id)
|
||||
except Device.DoesNotExist:
|
||||
return Response(
|
||||
{'error': f'Health device {health_device_id} not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Get health metrics from telemetry
|
||||
health_metrics = {}
|
||||
health_metric_names = {
|
||||
'steps': ['steps', 'step_count'],
|
||||
'active_calories': ['active_calories', 'calories'],
|
||||
'heart_rate': ['heart_rate', 'heart_rate_bpm', 'hr'],
|
||||
'resting_heart_rate': ['resting_heart_rate', 'resting_hr'],
|
||||
'sleep_duration': ['sleep_duration', 'sleep'],
|
||||
}
|
||||
|
||||
for key, possible_names in health_metric_names.items():
|
||||
for metric_name in possible_names:
|
||||
latest = (
|
||||
Telemetry.objects
|
||||
.filter(device_id=health_device_id, metric=metric_name)
|
||||
.order_by('-time')
|
||||
.first()
|
||||
)
|
||||
if latest:
|
||||
health_metrics[key] = float(latest.value)
|
||||
break
|
||||
|
||||
# Get environmental context (optional)
|
||||
env_context = EnvironmentalContext()
|
||||
if city:
|
||||
try:
|
||||
weather_data = weather_client.get_weather_by_city(city)
|
||||
env_context.temperature = weather_data.get('temperature')
|
||||
env_context.humidity = weather_data.get('humidity')
|
||||
env_context.weather_description = weather_data.get('weather_description')
|
||||
except Exception:
|
||||
pass # Weather optional
|
||||
|
||||
try:
|
||||
raw_air_quality = weather_client.get_air_quality(city.lower())
|
||||
air_quality_data = weather_client.parse_air_quality_data(raw_air_quality, city.lower())
|
||||
env_context.pm25 = air_quality_data.get('measurements', {}).get('pm25', {}).get('average')
|
||||
env_context.pm10 = air_quality_data.get('measurements', {}).get('pm10', {}).get('average')
|
||||
env_context.air_quality_status = air_quality_data.get('status')
|
||||
except Exception:
|
||||
pass # Air quality optional
|
||||
|
||||
# Prepare health metrics
|
||||
health = HealthMetrics(
|
||||
steps=int(health_metrics.get('steps', 0)),
|
||||
active_calories=int(health_metrics.get('active_calories', 0)),
|
||||
heart_rate=health_metrics.get('heart_rate'),
|
||||
resting_heart_rate=health_metrics.get('resting_heart_rate'),
|
||||
sleep_duration=health_metrics.get('sleep_duration'),
|
||||
)
|
||||
|
||||
# Calculate insights
|
||||
result = HealthInsightsCalculator.calculate(health, env_context)
|
||||
|
||||
# Format insights for response
|
||||
insights_list = [
|
||||
{
|
||||
'metric': insight.metric,
|
||||
'value': insight.value,
|
||||
'context': insight.context,
|
||||
'correlation': insight.correlation,
|
||||
'recommendation': insight.recommendation,
|
||||
}
|
||||
for insight in result.insights
|
||||
]
|
||||
|
||||
return Response({
|
||||
'health_metrics': result.health_metrics,
|
||||
'environmental_context': result.environmental_context,
|
||||
'insights': insights_list,
|
||||
'correlations': result.correlations,
|
||||
'recommendations': result.recommendations,
|
||||
'trend_indicators': result.trend_indicators,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Failed to calculate health insights: {str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def daily_briefing(self, request):
|
||||
"""
|
||||
Generate a daily briefing combining environment, schedule, and health data.
|
||||
|
||||
POST body:
|
||||
- briefing_type: 'schedule', 'environment', or 'full' (required)
|
||||
- city: City name for weather/air quality (required)
|
||||
- health_device_id: Device ID for health metrics (optional)
|
||||
- calendar_url: iCal URL for calendar events (optional)
|
||||
- calendar_range_hours: Hours to look ahead for events (default 24)
|
||||
"""
|
||||
briefing_type = request.data.get('briefing_type', 'full')
|
||||
city = request.data.get('city')
|
||||
health_device_id = request.data.get('health_device_id')
|
||||
calendar_url = request.data.get('calendar_url')
|
||||
calendar_range_hours = int(request.data.get('calendar_range_hours', 24))
|
||||
|
||||
if briefing_type not in ('schedule', 'environment', 'full'):
|
||||
return Response(
|
||||
{'error': 'briefing_type must be schedule, environment, or full'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if not city:
|
||||
return Response(
|
||||
{'error': 'city parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
current_time = timezone.now()
|
||||
|
||||
# Gather indoor data from all devices
|
||||
indoor_data = {}
|
||||
indoor_metrics = ['temperature', 'humidity', 'co2', 'CO2', 'noise', 'light', 'pm25', 'pm2.5']
|
||||
|
||||
for metric in indoor_metrics:
|
||||
latest = (
|
||||
Telemetry.objects
|
||||
.filter(metric__iexact=metric)
|
||||
.order_by('-time')
|
||||
.first()
|
||||
)
|
||||
if latest:
|
||||
# Normalize metric names
|
||||
normalized = metric.lower().replace('.', '')
|
||||
if normalized == 'co2':
|
||||
indoor_data['co2_ppm'] = float(latest.value)
|
||||
elif normalized == 'pm25':
|
||||
indoor_data['indoor_pm25'] = float(latest.value)
|
||||
else:
|
||||
indoor_data[f'{normalized}'] = float(latest.value)
|
||||
|
||||
# Gather outdoor data (weather + air quality)
|
||||
outdoor_data = {}
|
||||
try:
|
||||
weather = weather_client.get_weather_by_city(city)
|
||||
outdoor_data['temperature'] = weather.get('temperature')
|
||||
outdoor_data['apparent_temperature'] = weather.get('apparent_temperature')
|
||||
outdoor_data['humidity'] = weather.get('humidity')
|
||||
outdoor_data['weather'] = weather.get('weather_description')
|
||||
outdoor_data['wind_speed'] = weather.get('wind_speed')
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to fetch weather: {e}") if hasattr(self, 'logger') else None
|
||||
|
||||
try:
|
||||
raw_aq = weather_client.get_air_quality(city.lower())
|
||||
aq = weather_client.parse_air_quality_data(raw_aq, city.lower())
|
||||
outdoor_data['pm25'] = aq.get('measurements', {}).get('pm25', {}).get('average')
|
||||
outdoor_data['pm10'] = aq.get('measurements', {}).get('pm10', {}).get('average')
|
||||
outdoor_data['air_quality_status'] = aq.get('status')
|
||||
except Exception as e:
|
||||
pass # Air quality optional
|
||||
|
||||
# Gather health data if device specified
|
||||
health_data = None
|
||||
if health_device_id:
|
||||
health_data = {}
|
||||
health_metric_names = {
|
||||
'steps': ['steps', 'step_count'],
|
||||
'active_calories': ['active_calories', 'calories'],
|
||||
'heart_rate': ['heart_rate', 'heart_rate_bpm', 'hr'],
|
||||
'resting_heart_rate': ['resting_heart_rate', 'resting_hr'],
|
||||
}
|
||||
|
||||
for key, possible_names in health_metric_names.items():
|
||||
for metric_name in possible_names:
|
||||
latest = (
|
||||
Telemetry.objects
|
||||
.filter(device_id=health_device_id, metric=metric_name)
|
||||
.order_by('-time')
|
||||
.first()
|
||||
)
|
||||
if latest:
|
||||
health_data[key] = float(latest.value)
|
||||
break
|
||||
|
||||
# Parse calendar events if URL provided
|
||||
calendar_events = None
|
||||
if calendar_url:
|
||||
try:
|
||||
cal_response = requests.get(calendar_url, timeout=10)
|
||||
cal_response.raise_for_status()
|
||||
cal_text = cal_response.text
|
||||
|
||||
# Parse iCal using icalendar library or simple parsing
|
||||
calendar_events = self._parse_ical_events(
|
||||
cal_text,
|
||||
current_time,
|
||||
calendar_range_hours
|
||||
)
|
||||
except Exception as e:
|
||||
pass # Calendar optional
|
||||
|
||||
# Call GPT service
|
||||
result = async_to_sync(gpt_service_client.get_gpt_client().generate_daily_briefing)(
|
||||
briefing_type=briefing_type,
|
||||
current_time=current_time.isoformat(),
|
||||
indoor_data=indoor_data if indoor_data else None,
|
||||
outdoor_data=outdoor_data if outdoor_data else None,
|
||||
health_data=health_data,
|
||||
calendar_events=calendar_events,
|
||||
)
|
||||
|
||||
# Add context data to response
|
||||
result['context'] = {
|
||||
'indoor': indoor_data,
|
||||
'outdoor': outdoor_data,
|
||||
'health': health_data,
|
||||
'calendar_event_count': len(calendar_events) if calendar_events else 0,
|
||||
}
|
||||
|
||||
return Response(result)
|
||||
|
||||
except gpt_service_client.GPTServiceError as e:
|
||||
return Response(
|
||||
{
|
||||
'error': e.message,
|
||||
'details': e.details,
|
||||
'gpt_service_available': False
|
||||
},
|
||||
status=e.status_code or status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Failed to generate daily briefing: {str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
def _parse_ical_events(self, ical_text: str, start_time, range_hours: int):
|
||||
"""Parse iCal text and extract events within the time range."""
|
||||
from datetime import timedelta
|
||||
import re
|
||||
|
||||
events = []
|
||||
end_time = start_time + timedelta(hours=range_hours)
|
||||
|
||||
# Simple iCal parsing (handles basic VEVENT blocks)
|
||||
vevent_pattern = re.compile(r'BEGIN:VEVENT.*?END:VEVENT', re.DOTALL)
|
||||
|
||||
for match in vevent_pattern.finditer(ical_text):
|
||||
event_text = match.group()
|
||||
event = {}
|
||||
|
||||
# Extract summary
|
||||
summary_match = re.search(r'SUMMARY[^:]*:(.+?)(?:\r?\n|$)', event_text)
|
||||
if summary_match:
|
||||
event['summary'] = summary_match.group(1).strip()
|
||||
else:
|
||||
event['summary'] = 'Untitled'
|
||||
|
||||
# Extract start time
|
||||
dtstart_match = re.search(r'DTSTART[^:]*:(\d{8}T?\d{0,6}Z?)', event_text)
|
||||
if dtstart_match:
|
||||
dt_str = dtstart_match.group(1)
|
||||
try:
|
||||
if 'T' in dt_str:
|
||||
# DateTime format
|
||||
if dt_str.endswith('Z'):
|
||||
dt = datetime.strptime(dt_str, '%Y%m%dT%H%M%SZ')
|
||||
else:
|
||||
dt = datetime.strptime(dt_str[:15], '%Y%m%dT%H%M%S')
|
||||
event['start'] = dt.strftime('%I:%M %p')
|
||||
else:
|
||||
# Date only (all-day event)
|
||||
dt = datetime.strptime(dt_str, '%Y%m%d')
|
||||
event['start'] = 'All day'
|
||||
|
||||
# Check if event is within range
|
||||
if dt.replace(tzinfo=None) < start_time.replace(tzinfo=None):
|
||||
continue
|
||||
if dt.replace(tzinfo=None) > 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)
|
||||
|
||||
|
||||
|
||||
65
iotDashboard/dashboard_models.py
Normal file
65
iotDashboard/dashboard_models.py
Normal file
@@ -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
|
||||
)
|
||||
|
||||
@@ -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]],
|
||||
|
||||
354
iotDashboard/health_insights.py
Normal file
354
iotDashboard/health_insights.py
Normal file
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
82
iotDashboard/migrations/0003_create_dashboard_layouts.py
Normal file
82
iotDashboard/migrations/0003_create_dashboard_layouts.py
Normal file
@@ -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',
|
||||
),
|
||||
]
|
||||
333
iotDashboard/run_suitability.py
Normal file
333
iotDashboard/run_suitability.py
Normal file
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user