Added Calendar viewer and Environment overview with AI, Health overviewer

This commit is contained in:
2025-11-29 00:05:41 +01:00
parent 8c699bd121
commit ab72c01999
33 changed files with 4436 additions and 383 deletions

View File

@@ -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,15 +82,17 @@ function AppLayout({ children }: { children: React.ReactNode }) {
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Toaster position="top-right" />
<Routes>
<Route path="/" element={<AppLayout><Dashboard /></AppLayout>} />
<Route path="/devices" element={<AppLayout><DeviceList /></AppLayout>} />
<Route path="/devices/add" element={<AppLayout><AddDevice /></AppLayout>} />
<Route path="/devices/:id" element={<AppLayout><DeviceDetail /></AppLayout>} />
</Routes>
</BrowserRouter>
<WellnessStateProvider>
<BrowserRouter>
<Toaster position="top-right" />
<Routes>
<Route path="/" element={<AppLayout><Dashboard /></AppLayout>} />
<Route path="/devices" element={<AppLayout><DeviceList /></AppLayout>} />
<Route path="/devices/add" element={<AppLayout><AddDevice /></AppLayout>} />
<Route path="/devices/:id" element={<AppLayout><DeviceDetail /></AppLayout>} />
</Routes>
</BrowserRouter>
</WellnessStateProvider>
</QueryClientProvider>
)
}

View File

@@ -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,
}),
};

View File

@@ -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')
return
}
// Validation
if (needsDevice && selectedDevices.length === 0) {
alert('Please select a device')
return
}
if (needsMetrics && selectedMetrics.length === 0) {
alert('Please select at least one metric')
return
}
const wellnessWidgets = ['run-suitability', 'health-stats']
if (wellnessWidgets.includes(widgetType) && !city) {
alert('Please enter a city for wellness widgets')
return
}
if (widgetType === 'calendar' && !calendarUrl.trim()) {
alert('Please provide an iCal URL')
return
}
if (widgetType === 'daily-briefing' && !city) {
alert('Please enter a city for daily briefing')
return
}
// Create title
@@ -78,6 +109,16 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
defaultTitle = `Weather - ${city}`
} else if (widgetType === 'air-quality') {
defaultTitle = `Air Quality - ${city}`
} else if (widgetType === 'run-suitability') {
defaultTitle = `Run Suitability - ${city}`
} else if (widgetType === 'health-stats') {
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
defaultTitle = `Health StaTts - ${selectedDevice?.name || 'Device'}`
} else if (widgetType === 'calendar') {
defaultTitle = 'Calendar Agenda'
} else if (widgetType === 'daily-briefing') {
const briefingLabels = { schedule: 'Schedule', environment: 'Environment', full: 'Full' }
defaultTitle = `${briefingLabels[briefingType]} Briefing`
} else {
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics)
@@ -87,8 +128,8 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
id: `widget-${Date.now()}`,
type: widgetType,
title: title || defaultTitle,
deviceIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedDevices,
metricIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedMetrics,
deviceIds: needsDevice ? selectedDevices : [],
metricIds: needsMetrics ? selectedMetrics : [],
timeframe: {
hours: timeframeHours,
},
@@ -96,13 +137,24 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
showLegend: true,
showGrid: true,
height: widgetType === 'line-chart' ? 300 : undefined,
city: widgetType === 'weather' || widgetType === 'air-quality' ? city : undefined,
city: (widgetType === 'weather' || widgetType === 'air-quality' || wellnessWidgets.includes(widgetType) || widgetType === 'daily-briefing') ? city : undefined,
},
calendar: widgetType === 'calendar' ? {
icalUrl: calendarUrl.trim(),
timeRangeHours: calendarRangeHours,
} : undefined,
briefing: widgetType === 'daily-briefing' ? {
briefingType,
city,
healthDeviceId: selectedDevices[0],
calendarUrl: calendarUrl.trim() || undefined,
calendarRangeHours: calendarRangeHours,
} : undefined,
position: {
x: 0,
y: 0,
w: widgetWidth,
h: widgetHeight,
h: ['run-suitability', 'health-stats', 'daily-briefing'].includes(widgetType) ? 2 : widgetHeight,
},
}
@@ -249,6 +301,58 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
<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,48 +430,53 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
</div>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Select Metric(s)</span>
<span className="label-text-alt">{selectedMetrics.length} selected</span>
</label>
{selectedDevices.length === 0 ? (
<div className="border rounded-lg p-4 text-center text-base-content/60">
Please select a device first
</div>
) : (
<>
{(['stat', 'gauge'].includes(widgetType)) && (
<div className="alert alert-info mb-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
<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"></path>
</svg>
<span className="text-sm">This widget type supports only one metric</span>
</div>
)}
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
{availableMetrics.length === 0 ? (
<div className="text-center text-base-content/60 py-4">
No metrics found for this device
</div>
) : (
availableMetrics.map((metric) => (
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
<input
type={(['stat', 'gauge'].includes(widgetType)) ? 'radio' : 'checkbox'}
name={(['stat', 'gauge'].includes(widgetType)) ? 'single-metric' : undefined}
className={(['stat', 'gauge'].includes(widgetType)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
checked={selectedMetrics.includes(metric)}
onChange={() => toggleMetric(metric)}
/>
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
</label>
))
)}
{/* 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>
<span className="label-text-alt">{selectedMetrics.length} selected</span>
</label>
{selectedDevices.length === 0 ? (
<div className="border rounded-lg p-4 text-center text-base-content/60">
Please select a device first
</div>
</>
)}
</div>
) : (
<>
{(['stat', 'gauge'].includes(widgetType)) && (
<div className="alert alert-info mb-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
<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"></path>
</svg>
<span className="text-sm">This widget type supports only one metric</span>
</div>
)}
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
{availableMetrics.length === 0 ? (
<div className="text-center text-base-content/60 py-4">
No metrics found for this device
</div>
) : (
availableMetrics.map((metric) => (
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
<input
type={(['stat', 'gauge'].includes(widgetType)) ? 'radio' : 'checkbox'}
name={(['stat', 'gauge'].includes(widgetType)) ? 'single-metric' : undefined}
className={(['stat', 'gauge'].includes(widgetType)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
checked={selectedMetrics.includes(metric)}
onChange={() => toggleMetric(metric)}
/>
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
</label>
))
)}
</div>
</>
)}
</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}>

View File

@@ -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,58 +234,216 @@ export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: Edi
</div>
{/* Metrics */}
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Select Metric(s)</span>
<span className="label-text-alt">{selectedMetrics.length} selected</span>
</label>
{(['stat', 'gauge'].includes(widget.type)) && (
<div className="alert alert-info mb-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
<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"></path>
</svg>
<span className="text-sm">This widget type supports only one metric</span>
</div>
)}
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
{availableMetrics.length === 0 ? (
<div className="text-center text-base-content/60 py-4">
Loading 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>
<span className="label-text-alt">{selectedMetrics.length} selected</span>
</label>
{(['stat', 'gauge'].includes(widget.type)) && (
<div className="alert alert-info mb-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
<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"></path>
</svg>
<span className="text-sm">This widget type supports only one metric</span>
</div>
) : (
availableMetrics.map((metric) => (
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
<input
type={(['stat', 'gauge'].includes(widget.type)) ? 'radio' : 'checkbox'}
name={(['stat', 'gauge'].includes(widget.type)) ? 'single-metric' : undefined}
className={(['stat', 'gauge'].includes(widget.type)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
checked={selectedMetrics.includes(metric)}
onChange={() => toggleMetric(metric)}
/>
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
</label>
))
)}
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
{!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>
) : (
availableMetrics.map((metric) => (
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
<input
type={(['stat', 'gauge'].includes(widget.type)) ? 'radio' : 'checkbox'}
name={(['stat', 'gauge'].includes(widget.type)) ? 'single-metric' : undefined}
className={(['stat', 'gauge'].includes(widget.type)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
checked={selectedMetrics.includes(metric)}
onChange={() => toggleMetric(metric)}
/>
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
</label>
))
)}
</div>
</div>
</div>
)}
{/* Time Range */}
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Time Range</span>
</label>
<select
className="select select-bordered"
value={timeframeHours}
onChange={(e) => setTimeframeHours(Number(e.target.value))}
>
<option value={1}>Last 1 hour</option>
<option value={6}>Last 6 hours</option>
<option value={24}>Last 24 hours</option>
<option value={168}>Last 7 days</option>
<option value={720}>Last 30 days</option>
</select>
</div>
{!['calendar', 'daily-briefing'].includes(widget.type) && (
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Time Range</span>
</label>
<select
className="select select-bordered"
value={timeframeHours}
onChange={(e) => setTimeframeHours(Number(e.target.value))}
>
<option value={1}>Last 1 hour</option>
<option value={6}>Last 6 hours</option>
<option value={24}>Last 24 hours</option>
<option value={168}>Last 7 days</option>
<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">

View File

@@ -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,20 +177,239 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
{analysis && showAnalysis && !isLoading && (
<div className="space-y-4 mt-4">
<div className="flex items-center justify-between">
<div className="badge badge-primary badge-lg">
{promptType === 'trend_summary' ? 'Trend Analysis' : 'Anomaly Detection'}
</div>
<div className="text-xs text-base-content/50">
{analysis.data_points_analyzed} data points analyzed
</div>
</div>
<div className="divider my-2"></div>
<div className="prose max-w-none">
<div className="whitespace-pre-wrap text-sm leading-relaxed max-h-96 overflow-y-auto">
{analysis.analysis}
</div>
</div>
{/* 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'}
</div>
<div className="text-xs text-base-content/50">
{analysis.data_points_analyzed} data points analyzed
</div>
</div>
<div className="divider my-2"></div>
<div className="prose max-w-none">
<div className="whitespace-pre-wrap text-sm leading-relaxed max-h-96 overflow-y-auto">
{analysis.analysis}
</div>
</div>
</>
)}
<div className="flex justify-end gap-2 mt-4">
<button
className="btn btn-ghost btn-sm"

View 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>
)
}

View File

@@ -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">

View 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>
)
}

View 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>
)
}

View File

@@ -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 || [
'#3b82f6', // blue
'#10b981', // green
'#f59e0b', // amber
'#ef4444', // red
'#8b5cf6', // purple
'#ec4899', // pink
]
// Memoize colors to avoid recreating array
const colors = useMemo(() =>
visualization?.colors || [
'#3b82f6', // blue
'#10b981', // green
'#f59e0b', // amber
'#ef4444', // red
'#8b5cf6', // purple
'#ec4899', // pink
],
[visualization?.colors]
)
// Memoize lines to avoid recreating on every render
const lines = useMemo(() =>
metricIds.map((metric, index) => (
<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)

View 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>
)
}

View File

@@ -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>
)}

View File

@@ -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)

View File

@@ -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'

View File

@@ -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,
}

View File

@@ -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;
}

View File

@@ -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>(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
return JSON.parse(stored) as DashboardConfig
}
} catch (error) {
console.error('Failed to load dashboard config:', error)
}
return DEFAULT_CONFIG
})
const [config, setConfig] = useState<DashboardConfig>(DEFAULT_CONFIG)
const [isLoading, setIsLoading] = useState(true)
const [layoutId, setLayoutId] = useState<number | null>(null)
const isInitialLoadRef = useRef(true)
// Persist to localStorage whenever config changes
// Load config from backend or localStorage
useEffect(() => {
const loadConfig = async () => {
setIsLoading(true)
try {
// Try to load from backend
try {
const response = await dashboardLayoutApi.getDefault()
const layout = response.data
setConfig(layout.config)
setLayoutId(layout.id)
} catch (error: any) {
// No default layout found, try to create one or use localStorage fallback
console.log('No default layout found, using localStorage or creating new')
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored) as DashboardConfig
setConfig(parsed)
// Save to backend
try {
const response = await dashboardLayoutApi.create({
name: 'default',
config: parsed,
is_default: true,
})
setLayoutId(response.data.id)
} catch (err) {
console.error('Failed to save to backend:', err)
}
}
}
} catch (error) {
console.error('Failed to load dashboard config:', error)
// Fallback to localStorage
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
setConfig(JSON.parse(stored) as DashboardConfig)
}
} catch (e) {
console.error('Failed to load from localStorage:', e)
}
} finally {
setIsLoading(false)
isInitialLoadRef.current = false
}
}
loadConfig()
}, [])
const saveConfig = async () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
} catch (error) {
console.error('Failed to save dashboard config:', error)
console.error('Failed to save to localStorage:', error)
}
// Save to backend
try {
if (layoutId) {
// Update existing layout
await dashboardLayoutApi.update(layoutId, {
config: config,
})
} else {
// Create new layout
const response = await dashboardLayoutApi.create({
name: 'default',
config: config,
is_default: true,
})
setLayoutId(response.data.id)
}
return true
} catch (error) {
console.error('Failed to save to backend:', error)
return false
}
}
// Only save to localStorage automatically (no backend saves)
useEffect(() => {
if (!isInitialLoadRef.current) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
} catch (error) {
console.error('Failed to save to localStorage:', error)
}
}
}, [config])
const addWidget = (widget: WidgetConfig) => {
setConfig((prev) => ({
...prev,
@@ -124,5 +214,7 @@ export function useDashboardConfig() {
resetConfig,
exportConfig,
importConfig,
saveConfig,
isLoading,
}
}

View 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,
}
}

View File

@@ -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 {

View 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
}

View File

@@ -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);
}
);

View File

@@ -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) => ({
i: widget.id,
x: widget.position?.x || 0,
y: widget.position?.y || 0,
w: widget.position?.w || 1,
h: widget.position?.h || 2,
minW: 1,
minH: 1,
maxW: 4,
}))
const layout = config.widgets.map((widget) => {
const position = widget.position ?? { x: 0, y: Infinity, w: 1, h: 1 }
return {
i: widget.id,
x: position.x ?? 0,
y: position.y ?? Infinity,
w: Math.max(position.w ?? 1, 1),
h: Math.max(position.h ?? 1, 1),
minW: 1,
minH: 1,
maxW: GRID_COLUMNS,
}
})
const handleWidgetHeightChange = useCallback(
(widgetId: string, contentHeight: number) => {
const widget = config.widgets.find((w) => w.id === widgetId)
if (!widget) return
const position = widget.position ?? { x: 0, y: Infinity, w: 1, h: 1 }
const currentRows = Math.max(position.h ?? 1, 1)
const desiredPixelHeight = contentHeight + HEIGHT_PADDING
const targetRows = Math.min(
MAX_AUTO_ROWS,
Math.max(1, Math.ceil(desiredPixelHeight / ROW_UNIT))
)
if (Math.abs(targetRows - currentRows) >= 1) {
updateWidget(widgetId, {
position: {
...position,
h: targetRows,
},
})
}
},
[config.widgets, updateWidget]
)
const handleExport = () => {
const json = exportConfig()
@@ -83,6 +128,21 @@ export default function Dashboard() {
reader.readAsText(file)
}
const handleSaveDashboard = async () => {
setIsSaving(true)
setSaveStatus('idle')
try {
const success = await saveConfig()
setSaveStatus(success ? 'success' : 'error')
} catch (error) {
console.error('Failed to save dashboard configuration:', error)
setSaveStatus('error')
} finally {
setIsSaving(false)
setTimeout(() => setSaveStatus('idle'), 3000)
}
}
return (
<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}
@@ -148,32 +231,34 @@ export default function Dashboard() {
</div>
</div>
) : (
<div className="w-full" ref={gridContainerRef}>
<GridLayout
className="layout"
layout={layout}
cols={4}
rowHeight={150}
width={gridWidth}
onLayoutChange={handleLayoutChange}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
isResizable={true}
isDraggable={true}
margin={[12, 12]}
>
{config.widgets.map((widget) => (
<div key={widget.id} className="h-full">
<WidgetContainer
config={widget}
onRemove={() => removeWidget(widget.id)}
onEdit={() => setEditingWidget(widget.id)}
/>
</div>
))}
</GridLayout>
</div>
<div className="w-full" ref={gridContainerRef}>
<GridLayout
className="layout"
layout={layout}
cols={GRID_COLUMNS}
rowHeight={ROW_HEIGHT}
width={gridWidth}
onLayoutChange={handleLayoutChange}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
isResizable={true}
isDraggable={true}
margin={GRID_MARGIN}
containerPadding={[0, 0]}
>
{config.widgets.map((widget) => (
<div key={widget.id} className="h-full">
<WidgetContainer
config={widget}
onRemove={() => removeWidget(widget.id)}
onEdit={() => setEditingWidget(widget.id)}
onHeightChange={(height: number) => handleWidgetHeightChange(widget.id, height)}
/>
</div>
))}
</GridLayout>
</div>
)}
<AddWidgetModal

View File

@@ -1,3 +1,5 @@
mosquitto/certs/
mosquitto/data/
mosquitto/logs/
.env
.env*

View File

@@ -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']

View File

@@ -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)),

View File

@@ -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)

View 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
)

View File

@@ -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]],

View 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,
)

View File

@@ -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
]

View 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',
),
]

View 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,
)

View File

@@ -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