mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 09:06:26 +00:00
Compare commits
2 Commits
8c699bd121
...
3ab81fad8c
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ab81fad8c | |||
| ab72c01999 |
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
|
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>iotDashboard</title>
|
<title>Lyncis</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
1050
frontend/package-lock.json
generated
1050
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,23 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
|
"@tanstack/react-query": "^5.90.10",
|
||||||
|
"@types/react-grid-layout": "^1.3.6",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"daisyui": "^5.5.5",
|
||||||
|
"gridstack": "^12.3.3",
|
||||||
|
"ical.js": "^2.2.1",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1"
|
"react-dom": "^19.1.1",
|
||||||
|
"react-grid-layout": "^1.5.2",
|
||||||
|
"react-hook-form": "^7.66.1",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-router-dom": "^7.9.6",
|
||||||
|
"recharts": "^3.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter, Routes, Route, Link, NavLink } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Link, NavLink } from 'react-router-dom'
|
||||||
import { Toaster } from 'react-hot-toast'
|
import { Toaster } from 'react-hot-toast'
|
||||||
|
import { WellnessStateProvider } from './hooks/useWellnessState'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import DeviceList from './pages/DeviceList'
|
import DeviceList from './pages/DeviceList'
|
||||||
import DeviceDetail from './pages/DeviceDetail'
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span>IoT Dashboard</span>
|
<span>Lyncis</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ function AppLayout({ children }: { children: React.ReactNode }) {
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<WellnessStateProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -90,6 +92,7 @@ function App() {
|
|||||||
<Route path="/devices/:id" element={<AppLayout><DeviceDetail /></AppLayout>} />
|
<Route path="/devices/:id" element={<AppLayout><DeviceDetail /></AppLayout>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</WellnessStateProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,3 +146,150 @@ export const weatherApi = {
|
|||||||
sensor_count: number;
|
sensor_count: number;
|
||||||
}>('/weather/air_quality/', { params: { city } }),
|
}>('/weather/air_quality/', { params: { city } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Dashboard Layout API
|
||||||
|
export const dashboardLayoutApi = {
|
||||||
|
getAll: () =>
|
||||||
|
apiClient.get<Array<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
config: any
|
||||||
|
is_default: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}>>('/dashboard-layouts/'),
|
||||||
|
|
||||||
|
getDefault: () =>
|
||||||
|
apiClient.get<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
config: any
|
||||||
|
is_default: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}>('/dashboard-layouts/default/'),
|
||||||
|
|
||||||
|
create: (data: {
|
||||||
|
name: string
|
||||||
|
config: any
|
||||||
|
is_default?: boolean
|
||||||
|
}) =>
|
||||||
|
apiClient.post('/dashboard-layouts/', data),
|
||||||
|
|
||||||
|
update: (id: number, data: {
|
||||||
|
name?: string
|
||||||
|
config?: any
|
||||||
|
is_default?: boolean
|
||||||
|
}) =>
|
||||||
|
apiClient.put(`/dashboard-layouts/${id}/`, data),
|
||||||
|
|
||||||
|
delete: (id: number) =>
|
||||||
|
apiClient.delete(`/dashboard-layouts/${id}/`),
|
||||||
|
|
||||||
|
setDefault: (id: number) =>
|
||||||
|
apiClient.post(`/dashboard-layouts/${id}/set_default/`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wellness API
|
||||||
|
export const wellnessApi = {
|
||||||
|
getRunSuitability: (healthDeviceId: string, city: string, timeOfDay?: string) =>
|
||||||
|
apiClient.get<{
|
||||||
|
status: 'GO' | 'MODERATE' | 'NO';
|
||||||
|
overall_score: number;
|
||||||
|
scores: {
|
||||||
|
weather: number;
|
||||||
|
air_quality: number;
|
||||||
|
health: number;
|
||||||
|
};
|
||||||
|
primary_reason: string;
|
||||||
|
detailed_insights: string[];
|
||||||
|
time_recommendations: string[];
|
||||||
|
suggestions: string[];
|
||||||
|
weather_data: {
|
||||||
|
temperature: number;
|
||||||
|
wind_speed: number;
|
||||||
|
precipitation: number;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
air_quality_data: {
|
||||||
|
pm25: number | null;
|
||||||
|
pm10: number | null;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
health_data: {
|
||||||
|
steps: number;
|
||||||
|
active_calories: number;
|
||||||
|
heart_rate: number | null;
|
||||||
|
};
|
||||||
|
}>('/wellness/run_suitability/', {
|
||||||
|
params: {
|
||||||
|
health_device_id: healthDeviceId,
|
||||||
|
city,
|
||||||
|
...(timeOfDay && { time_of_day: timeOfDay }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
getHealthInsights: (healthDeviceId: string, city?: string) =>
|
||||||
|
apiClient.get<{
|
||||||
|
health_metrics: {
|
||||||
|
steps: number;
|
||||||
|
active_calories: number;
|
||||||
|
heart_rate: number | null;
|
||||||
|
resting_heart_rate: number | null;
|
||||||
|
};
|
||||||
|
environmental_context: {
|
||||||
|
temperature: number | null;
|
||||||
|
humidity: number | null;
|
||||||
|
pm25: number | null;
|
||||||
|
pm10: number | null;
|
||||||
|
air_quality_status: string | null;
|
||||||
|
weather_description: string | null;
|
||||||
|
};
|
||||||
|
insights: Array<{
|
||||||
|
metric: string;
|
||||||
|
value: number;
|
||||||
|
context: string;
|
||||||
|
correlation: string | null;
|
||||||
|
recommendation: string | null;
|
||||||
|
}>;
|
||||||
|
correlations: string[];
|
||||||
|
recommendations: string[];
|
||||||
|
trend_indicators: string[];
|
||||||
|
}>('/wellness/health_insights/', {
|
||||||
|
params: {
|
||||||
|
health_device_id: healthDeviceId,
|
||||||
|
...(city && { city }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
getDailyBriefing: (params: {
|
||||||
|
briefing_type: 'schedule' | 'environment' | 'full';
|
||||||
|
city: string;
|
||||||
|
health_device_id?: string;
|
||||||
|
calendar_url?: string;
|
||||||
|
calendar_range_hours?: number;
|
||||||
|
}) =>
|
||||||
|
apiClient.post<{
|
||||||
|
status_emoji: string;
|
||||||
|
status_line: string;
|
||||||
|
insights: string[];
|
||||||
|
recommendations: string[];
|
||||||
|
briefing_type: string;
|
||||||
|
generated_at: string;
|
||||||
|
context: {
|
||||||
|
indoor: Record<string, number> | null;
|
||||||
|
outdoor: Record<string, number | string | null> | null;
|
||||||
|
health: Record<string, number> | null;
|
||||||
|
calendar_event_count: number;
|
||||||
|
};
|
||||||
|
}>('/wellness/daily_briefing/', params),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calendar API
|
||||||
|
export const calendarApi = {
|
||||||
|
fetch: (calendarUrl: string, options?: { signal?: AbortSignal }) =>
|
||||||
|
apiClient.get<{ calendar_data: string }>('/calendar/fetch/', {
|
||||||
|
params: { calendar_url: calendarUrl },
|
||||||
|
signal: options?.signal,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
|
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
|
||||||
const [timeframeHours, setTimeframeHours] = useState(24)
|
const [timeframeHours, setTimeframeHours] = useState(24)
|
||||||
const [widgetWidth, setWidgetWidth] = useState(1)
|
const [widgetWidth, setWidgetWidth] = useState(1)
|
||||||
const [widgetHeight, setWidgetHeight] = useState(2)
|
const [widgetHeight, setWidgetHeight] = useState(3)
|
||||||
const [city, setCity] = useState('Skopje')
|
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
|
// Fetch devices
|
||||||
const { data: devicesData } = useQuery({
|
const { data: devicesData } = useQuery({
|
||||||
@@ -44,6 +48,10 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
const devices = devicesData?.results || []
|
const devices = devicesData?.results || []
|
||||||
const availableMetrics = deviceMetricsData || []
|
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
|
// Reset form when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -54,7 +62,11 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
setSelectedMetrics([])
|
setSelectedMetrics([])
|
||||||
setTimeframeHours(24)
|
setTimeframeHours(24)
|
||||||
setWidgetWidth(1)
|
setWidgetWidth(1)
|
||||||
setWidgetHeight(2)
|
setWidgetHeight(3)
|
||||||
|
setCalendarUrl('')
|
||||||
|
setShowCalendarUrl(false)
|
||||||
|
setCalendarRangeHours(72)
|
||||||
|
setBriefingType('full')
|
||||||
}
|
}
|
||||||
}, [isOpen])
|
}, [isOpen])
|
||||||
|
|
||||||
@@ -64,12 +76,31 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
}, [selectedDevices])
|
}, [selectedDevices])
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
// Weather and air-quality widgets don't need device/metric validation
|
// Validation
|
||||||
if (widgetType !== 'weather' && widgetType !== 'air-quality') {
|
if (needsDevice && selectedDevices.length === 0) {
|
||||||
if (selectedDevices.length === 0 || selectedMetrics.length === 0) {
|
alert('Please select a device')
|
||||||
alert('Please select at least one device and one metric')
|
|
||||||
return
|
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
|
// Create title
|
||||||
@@ -78,6 +109,16 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
defaultTitle = `Weather - ${city}`
|
defaultTitle = `Weather - ${city}`
|
||||||
} else if (widgetType === 'air-quality') {
|
} else if (widgetType === 'air-quality') {
|
||||||
defaultTitle = `Air Quality - ${city}`
|
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 {
|
} else {
|
||||||
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
|
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
|
||||||
defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics)
|
defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics)
|
||||||
@@ -87,8 +128,8 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
id: `widget-${Date.now()}`,
|
id: `widget-${Date.now()}`,
|
||||||
type: widgetType,
|
type: widgetType,
|
||||||
title: title || defaultTitle,
|
title: title || defaultTitle,
|
||||||
deviceIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedDevices,
|
deviceIds: needsDevice ? selectedDevices : [],
|
||||||
metricIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedMetrics,
|
metricIds: needsMetrics ? selectedMetrics : [],
|
||||||
timeframe: {
|
timeframe: {
|
||||||
hours: timeframeHours,
|
hours: timeframeHours,
|
||||||
},
|
},
|
||||||
@@ -96,13 +137,24 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
showLegend: true,
|
showLegend: true,
|
||||||
showGrid: true,
|
showGrid: true,
|
||||||
height: widgetType === 'line-chart' ? 300 : undefined,
|
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: {
|
position: {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
w: widgetWidth,
|
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 className="text-xs opacity-70">Multi-factor</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -260,7 +364,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Skip data source step for weather and air quality widgets
|
// 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)
|
setStep(3)
|
||||||
} else {
|
} else {
|
||||||
setStep(2)
|
setStep(2)
|
||||||
@@ -274,8 +378,29 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2: Data Source */}
|
{/* 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">
|
<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">
|
<div className="form-control">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text font-semibold">Select Device</span>
|
<span className="label-text font-semibold">Select Device</span>
|
||||||
@@ -305,6 +430,10 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Metric Selection - Skip for widgets with hardcoded metrics */}
|
||||||
|
{!['run-suitability', 'health-stats'].includes(widgetType) && (
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text font-semibold">Select Metric(s)</span>
|
<span className="label-text font-semibold">Select Metric(s)</span>
|
||||||
@@ -347,6 +476,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="modal-action">
|
<div className="modal-action">
|
||||||
<button className="btn btn-ghost" onClick={() => setStep(1)}>
|
<button className="btn btn-ghost" onClick={() => setStep(1)}>
|
||||||
@@ -354,8 +484,12 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
|
|
||||||
onClick={() => setStep(3)}
|
onClick={() => setStep(3)}
|
||||||
disabled={selectedDevices.length === 0 || selectedMetrics.length === 0}
|
disabled={
|
||||||
|
(needsDevice && selectedDevices.length === 0) ||
|
||||||
|
(needsMetrics && selectedMetrics.length === 0)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
@@ -402,6 +536,194 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
// Original configuration for sensor-based widgets
|
||||||
<>
|
<>
|
||||||
@@ -478,14 +800,23 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div className="font-semibold">Widget Summary</div>
|
<div className="font-semibold">Widget Summary</div>
|
||||||
<div>Type: <span className="badge badge-sm">{widgetType}</span></div>
|
<div>Type: <span className="badge badge-sm">{widgetType}</span></div>
|
||||||
<div>Device: {devices.find(d => d.id === selectedDevices[0])?.name}</div>
|
<div>Device: {needsDevice ? (devices.find(d => d.id === selectedDevices[0])?.name || 'Not selected') : 'Not required'}</div>
|
||||||
<div>Metrics: {selectedMetrics.join(', ')}</div>
|
<div>Metrics: {needsMetrics ? (selectedMetrics.join(', ') || 'Not selected') : 'Not required'}</div>
|
||||||
<div>Size: {widgetWidth} × {widgetHeight}</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>
|
</div>
|
||||||
|
|
||||||
<div className="modal-action">
|
<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
|
Back
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary" onClick={handleSubmit}>
|
<button className="btn btn-primary" onClick={handleSubmit}>
|
||||||
|
|||||||
@@ -12,44 +12,140 @@ interface EditWidgetModalProps {
|
|||||||
|
|
||||||
export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: EditWidgetModalProps) {
|
export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: EditWidgetModalProps) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
|
const [selectedDeviceId, setSelectedDeviceId] = useState('')
|
||||||
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
|
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
|
||||||
const [timeframeHours, setTimeframeHours] = useState(24)
|
const [timeframeHours, setTimeframeHours] = useState(24)
|
||||||
const [widgetWidth, setWidgetWidth] = useState(1)
|
const [widgetWidth, setWidgetWidth] = useState(1)
|
||||||
const [widgetHeight, setWidgetHeight] = useState(2)
|
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
|
// Fetch all devices
|
||||||
const { data: deviceMetricsData } = useQuery({
|
const { data: devicesData } = useQuery({
|
||||||
queryKey: ['device-metrics', widget?.deviceIds[0]],
|
queryKey: ['devices'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!widget?.deviceIds[0]) return []
|
const response = await devicesApi.getAll()
|
||||||
const response = await devicesApi.getMetrics(widget.deviceIds[0])
|
return response.data.results
|
||||||
return response.data.metrics
|
|
||||||
},
|
},
|
||||||
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
|
// Load widget data when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && widget) {
|
if (isOpen && widget) {
|
||||||
setTitle(widget.title || '')
|
setTitle(widget.title || '')
|
||||||
|
setSelectedDeviceId(widget.deviceIds[0] || widget.briefing?.healthDeviceId || '')
|
||||||
setSelectedMetrics(widget.metricIds || [])
|
setSelectedMetrics(widget.metricIds || [])
|
||||||
setTimeframeHours(widget.timeframe?.hours || 24)
|
setTimeframeHours(widget.timeframe?.hours || 24)
|
||||||
setWidgetWidth(widget.position?.w || 1)
|
setWidgetWidth(widget.position?.w || 1)
|
||||||
setWidgetHeight(widget.position?.h || 2)
|
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])
|
}, [isOpen, widget])
|
||||||
|
|
||||||
|
// Reset metrics when device changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDeviceId && widget && selectedDeviceId !== widget.deviceIds[0]) {
|
||||||
|
setSelectedMetrics([])
|
||||||
|
}
|
||||||
|
}, [selectedDeviceId, widget])
|
||||||
|
|
||||||
const handleSubmit = () => {
|
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')
|
alert('Please select at least one metric')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
onSave(widget.id, {
|
onSave(widget.id, {
|
||||||
title,
|
title,
|
||||||
metricIds: selectedMetrics,
|
deviceIds: needsDevice ? [selectedDeviceId] : [],
|
||||||
|
metricIds: needsMetrics ? selectedMetrics : [],
|
||||||
timeframe: {
|
timeframe: {
|
||||||
hours: timeframeHours,
|
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 className="badge badge-lg badge-primary">{widget.type}</div>
|
||||||
</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 */}
|
{/* Title */}
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
@@ -117,6 +234,7 @@ export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: Edi
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metrics */}
|
{/* Metrics */}
|
||||||
|
{!['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type) && (
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text font-semibold">Select Metric(s)</span>
|
<span className="label-text font-semibold">Select Metric(s)</span>
|
||||||
@@ -131,7 +249,11 @@ export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: Edi
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
|
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
|
||||||
{availableMetrics.length === 0 ? (
|
{!selectedDeviceId ? (
|
||||||
|
<div className="text-center text-base-content/60 py-4">
|
||||||
|
Please select a device first
|
||||||
|
</div>
|
||||||
|
) : availableMetrics.length === 0 ? (
|
||||||
<div className="text-center text-base-content/60 py-4">
|
<div className="text-center text-base-content/60 py-4">
|
||||||
Loading metrics...
|
Loading metrics...
|
||||||
</div>
|
</div>
|
||||||
@@ -151,8 +273,10 @@ export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: Edi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Time Range */}
|
{/* Time Range */}
|
||||||
|
{!['calendar', 'daily-briefing'].includes(widget.type) && (
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text font-semibold">Time Range</span>
|
<span className="label-text font-semibold">Time Range</span>
|
||||||
@@ -169,6 +293,157 @@ export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: Edi
|
|||||||
<option value={720}>Last 30 days</option>
|
<option value={720}>Last 30 days</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 */}
|
{/* Size */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -7,6 +7,38 @@ interface AiInsightWidgetProps {
|
|||||||
config: WidgetConfig
|
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) {
|
export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
||||||
const { deviceIds, metricIds, timeframe, title } = config
|
const { deviceIds, metricIds, timeframe, title } = config
|
||||||
const [promptType, setPromptType] = useState<'trend_summary' | 'anomaly_detection'>('trend_summary')
|
const [promptType, setPromptType] = useState<'trend_summary' | 'anomaly_detection'>('trend_summary')
|
||||||
@@ -40,6 +72,52 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
|||||||
refetch()
|
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 (
|
return (
|
||||||
<div className="card bg-base-100 shadow">
|
<div className="card bg-base-100 shadow">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
@@ -99,6 +177,222 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
|||||||
|
|
||||||
{analysis && showAnalysis && !isLoading && (
|
{analysis && showAnalysis && !isLoading && (
|
||||||
<div className="space-y-4 mt-4">
|
<div className="space-y-4 mt-4">
|
||||||
|
{/* Structured Display for Trend Summary */}
|
||||||
|
{isTrendSummary && parsedAnalysis && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className={`badge badge-lg ${getStatusColor(parsedAnalysis.status)}`}>
|
||||||
|
{parsedAnalysis.status.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-base-content/50">
|
||||||
|
{analysis.data_points_analyzed} data points
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="alert alert-info">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold">Summary</div>
|
||||||
|
<div className="text-sm">{parsedAnalysis.summary}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comfort Score */}
|
||||||
|
{parsedAnalysis.comfort_score && (
|
||||||
|
<div className="card bg-base-200">
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="radial-progress text-primary" style={{ "--value": parsedAnalysis.comfort_score.rating } as React.CSSProperties}>
|
||||||
|
{parsedAnalysis.comfort_score.rating}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold">Comfort Score</div>
|
||||||
|
<div className="text-sm text-base-content/70">{parsedAnalysis.comfort_score.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Trends */}
|
||||||
|
{parsedAnalysis.trends && parsedAnalysis.trends.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-sm mb-2">Trends</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{parsedAnalysis.trends.map((trend: TrendSummary['trends'][0], i: number) => (
|
||||||
|
<div key={i} className="card bg-base-200">
|
||||||
|
<div className="card-body p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-lg">{getDirectionIcon(trend.direction)}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-semibold text-sm">{trend.metric}</div>
|
||||||
|
<div className="text-xs text-base-content/70">{trend.description}</div>
|
||||||
|
</div>
|
||||||
|
<div className="badge badge-sm">{trend.direction}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Patterns */}
|
||||||
|
{parsedAnalysis.patterns && parsedAnalysis.patterns.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-sm mb-2">Patterns Detected</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{parsedAnalysis.patterns.map((pattern: string, i: number) => (
|
||||||
|
<li key={i} className="text-sm flex items-start gap-2">
|
||||||
|
<span className="text-primary mt-0.5">▸</span>
|
||||||
|
<span>{pattern}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recommendations */}
|
||||||
|
{parsedAnalysis.recommendations && parsedAnalysis.recommendations.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-sm mb-2">Recommendations</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{parsedAnalysis.recommendations.map((rec: string, i: number) => (
|
||||||
|
<div key={i} className="alert alert-success alert-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm">{rec}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Forecast */}
|
||||||
|
{parsedAnalysis.forecast && (
|
||||||
|
<div className="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-sm">Forecast</div>
|
||||||
|
<div className="text-xs">{parsedAnalysis.forecast}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Structured Display for Anomaly Detection */}
|
||||||
|
{isAnomalyDetection && parsedAnalysis && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className={`badge badge-lg ${getStatusColor(parsedAnalysis.status)}`}>
|
||||||
|
{parsedAnalysis.status.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-base-content/50">
|
||||||
|
{analysis.data_points_analyzed} data points
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className={`alert ${parsedAnalysis.status === 'critical' ? 'alert-error' : parsedAnalysis.status === 'warning' ? 'alert-warning' : 'alert-success'}`}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold">Summary</div>
|
||||||
|
<div className="text-sm">{parsedAnalysis.summary}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Anomalies */}
|
||||||
|
{parsedAnalysis.anomalies && parsedAnalysis.anomalies.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-sm mb-2">Anomalies Detected</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{parsedAnalysis.anomalies.map((anomaly: AnomalyDetection['anomalies'][0], i: number) => (
|
||||||
|
<div key={i} className="card bg-base-200 border-l-4 border-error">
|
||||||
|
<div className="card-body p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-semibold text-sm">{anomaly.metric}</span>
|
||||||
|
<span className={`badge badge-sm ${getSeverityColor(anomaly.severity)}`}>
|
||||||
|
{anomaly.severity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-base-content/70 mb-1">{anomaly.description}</div>
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-error font-semibold">Current: {anomaly.value}</span>
|
||||||
|
{' • '}
|
||||||
|
<span className="text-base-content/60">Expected: {anomaly.expected}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Impacts */}
|
||||||
|
{parsedAnalysis.impacts && parsedAnalysis.impacts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-sm mb-2">Potential Impacts</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{parsedAnalysis.impacts.map((impact: string, i: number) => (
|
||||||
|
<li key={i} className="text-sm flex items-start gap-2">
|
||||||
|
<span className="text-warning mt-0.5">▸</span>
|
||||||
|
<span>{impact}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{parsedAnalysis.actions && parsedAnalysis.actions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-sm mb-2">Recommended Actions</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{parsedAnalysis.actions.map((action: string, i: number) => (
|
||||||
|
<div key={i} className="alert alert-warning alert-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm">{action}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Root Causes */}
|
||||||
|
{parsedAnalysis.root_causes && parsedAnalysis.root_causes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-sm mb-2">Possible Root Causes</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{parsedAnalysis.root_causes.map((cause: string, i: number) => (
|
||||||
|
<li key={i} className="text-sm flex items-start gap-2">
|
||||||
|
<span className="text-info mt-0.5">▸</span>
|
||||||
|
<span>{cause}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fallback: Raw Text Display */}
|
||||||
|
{!parsedAnalysis && (
|
||||||
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="badge badge-primary badge-lg">
|
<div className="badge badge-primary badge-lg">
|
||||||
{promptType === 'trend_summary' ? 'Trend Analysis' : 'Anomaly Detection'}
|
{promptType === 'trend_summary' ? 'Trend Analysis' : 'Anomaly Detection'}
|
||||||
@@ -113,6 +407,9 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
|||||||
{analysis.analysis}
|
{analysis.analysis}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-4">
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
|
|||||||
308
frontend/src/components/widgets/CalendarWidget.tsx
Normal file
308
frontend/src/components/widgets/CalendarWidget.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import type { AxiosError } from 'axios'
|
||||||
|
import ICAL from 'ical.js'
|
||||||
|
import { calendarApi } from '../../api'
|
||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
import './widget-styles.css'
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL_MS = 5 * 60 * 1000
|
||||||
|
const MAX_EVENTS = 25
|
||||||
|
|
||||||
|
interface CalendarWidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgendaEvent {
|
||||||
|
id: string
|
||||||
|
summary: string
|
||||||
|
start: Date
|
||||||
|
end: Date
|
||||||
|
location?: string | null
|
||||||
|
description?: string | null
|
||||||
|
isAllDay: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||||
|
|
||||||
|
function formatDayLabel(date: Date) {
|
||||||
|
const today = new Date()
|
||||||
|
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||||
|
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
const diffMs = startOfDate.getTime() - startOfToday.getTime()
|
||||||
|
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
|
||||||
|
|
||||||
|
if (Math.abs(diffDays) <= 1) {
|
||||||
|
return relativeFormatter.format(diffDays, 'day')
|
||||||
|
}
|
||||||
|
|
||||||
|
return dayFormatter.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventRange(event: AgendaEvent) {
|
||||||
|
const sameDay = event.start.toDateString() === event.end.toDateString()
|
||||||
|
const dayLabel = formatDayLabel(event.start)
|
||||||
|
|
||||||
|
if (event.isAllDay) {
|
||||||
|
return `${dayLabel} - All day`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sameDay) {
|
||||||
|
return `${dayLabel} - ${timeFormatter.format(event.start)} to ${timeFormatter.format(event.end)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${dayLabel} ${timeFormatter.format(event.start)} -> ${formatDayLabel(event.end)} ${timeFormatter.format(event.end)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAgendaEvents(
|
||||||
|
component: ICAL.Component,
|
||||||
|
windowStart: Date,
|
||||||
|
windowEnd: Date,
|
||||||
|
): AgendaEvent[] {
|
||||||
|
const event = new ICAL.Event(component)
|
||||||
|
const results: AgendaEvent[] = []
|
||||||
|
const eventTemplate = {
|
||||||
|
summary: event.summary || 'Untitled event',
|
||||||
|
location: event.location || null,
|
||||||
|
description: event.description || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const addOccurrence = (start: ICAL.Time, end?: ICAL.Time | null) => {
|
||||||
|
const jsStart = start.toJSDate()
|
||||||
|
const jsEnd = (end || start).toJSDate()
|
||||||
|
|
||||||
|
if (jsEnd < windowStart || jsStart > windowEnd) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
id: `${event.uid || event.summary}-${jsStart.toISOString()}`,
|
||||||
|
summary: eventTemplate.summary,
|
||||||
|
location: eventTemplate.location,
|
||||||
|
description: eventTemplate.description,
|
||||||
|
start: jsStart,
|
||||||
|
end: jsEnd,
|
||||||
|
isAllDay: start.isDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlapWindowStart = new Date(windowStart.getTime() - 24 * 60 * 60 * 1000)
|
||||||
|
const iteratorStart = ICAL.Time.fromJSDate(overlapWindowStart)
|
||||||
|
|
||||||
|
if (event.isRecurring()) {
|
||||||
|
const iterator = event.iterator(iteratorStart)
|
||||||
|
let next = iterator.next()
|
||||||
|
while (next) {
|
||||||
|
const occurrence = event.getOccurrenceDetails(next)
|
||||||
|
addOccurrence(occurrence.startDate, occurrence.endDate)
|
||||||
|
const jsStart = occurrence.startDate.toJSDate()
|
||||||
|
if (jsStart > windowEnd) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
next = iterator.next()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addOccurrence(event.startDate, event.endDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarWidget({ config }: CalendarWidgetProps) {
|
||||||
|
const calendarConfig = config.calendar
|
||||||
|
const [events, setEvents] = useState<AgendaEvent[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
const requestIdRef = useRef(0)
|
||||||
|
|
||||||
|
const fetchEvents = useCallback(async () => {
|
||||||
|
if (!calendarConfig?.icalUrl) {
|
||||||
|
setEvents([])
|
||||||
|
setError('Calendar URL is missing')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
abortRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
const requestId = requestIdRef.current + 1
|
||||||
|
requestIdRef.current = requestId
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await calendarApi.fetch(calendarConfig.icalUrl, {
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
const raw = response.data?.calendar_data
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error('Calendar feed was empty')
|
||||||
|
}
|
||||||
|
const parsed = ICAL.parse(raw)
|
||||||
|
const comp = new ICAL.Component(parsed)
|
||||||
|
const vevents = comp.getAllSubcomponents('vevent') || []
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const windowStart = new Date(now.getTime() - 30 * 60 * 1000) // keep events that started recently
|
||||||
|
const windowEnd = new Date(now.getTime() + (calendarConfig.timeRangeHours || 72) * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
const agendaEvents = vevents
|
||||||
|
.flatMap((vevent) => createAgendaEvents(vevent, windowStart, windowEnd))
|
||||||
|
.sort((a, b) => a.start.getTime() - b.start.getTime())
|
||||||
|
.slice(0, MAX_EVENTS)
|
||||||
|
|
||||||
|
setEvents(agendaEvents)
|
||||||
|
setLastUpdated(new Date())
|
||||||
|
} catch (err) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const axiosError = err as AxiosError<{ error?: string; detail?: string }>
|
||||||
|
const message = axiosError?.response?.data?.error
|
||||||
|
|| axiosError?.response?.data?.detail
|
||||||
|
|| axiosError?.message
|
||||||
|
|| 'Failed to load calendar'
|
||||||
|
setError(message)
|
||||||
|
setEvents([])
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted && requestId === requestIdRef.current) {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [calendarConfig?.icalUrl, calendarConfig?.timeRangeHours])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!calendarConfig?.icalUrl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchEvents()
|
||||||
|
const interval = setInterval(fetchEvents, REFRESH_INTERVAL_MS)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval)
|
||||||
|
abortRef.current?.abort()
|
||||||
|
}
|
||||||
|
}, [calendarConfig?.icalUrl, fetchEvents])
|
||||||
|
|
||||||
|
const handleManualRefresh = () => {
|
||||||
|
fetchEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!calendarConfig) {
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body p-4 text-sm">
|
||||||
|
<h2 className="card-title text-sm mb-2">{config.title || 'Calendar'}</h2>
|
||||||
|
<p className="opacity-70">
|
||||||
|
Configure an iCal URL to see your agenda.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeLabel = `Next ${calendarConfig.timeRangeHours || 72}h`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body p-3 h-full flex flex-col gap-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide opacity-60">Agenda</p>
|
||||||
|
<h2 className="card-title text-sm leading-tight">{config.title || 'Calendar'}</h2>
|
||||||
|
<p className="text-xs opacity-60">{rangeLabel}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-xs btn-outline"
|
||||||
|
onClick={handleManualRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
{lastUpdated && (
|
||||||
|
<span className="text-[10px] opacity-60">
|
||||||
|
Updated {timeFormatter.format(lastUpdated)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-error text-xs">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && events.length === 0 && !isLoading && (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-sm opacity-60 text-center">
|
||||||
|
No upcoming events in this window.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && events.length === 0 && (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<span className="loading loading-spinner"></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{events.length > 0 && (
|
||||||
|
<ul className="flex-1 overflow-y-auto divide-y divide-base-200">
|
||||||
|
{events.map((event) => (
|
||||||
|
<li key={event.id} className="py-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold leading-tight">
|
||||||
|
{event.summary || 'Untitled event'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs opacity-70">
|
||||||
|
{formatEventRange(event)}
|
||||||
|
</p>
|
||||||
|
{event.location && (
|
||||||
|
<p className="text-[11px] opacity-70 mt-1 flex items-center gap-1">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 11c1.38 0 2.5-1.12 2.5-2.5S13.38 6 12 6s-2.5 1.12-2.5 2.5S10.62 11 12 11zm0 0c-4 0-5 4-5 4v.5a2.5 2.5 0 002.5 2.5h5a2.5 2.5 0 002.5-2.5V15s-1-4-5-4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{event.location}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-semibold text-right whitespace-nowrap">
|
||||||
|
{event.isAllDay ? 'All day' : timeFormatter.format(event.start)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,61 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { devicesApi } from '../../api'
|
import { devicesApi } from '../../api'
|
||||||
import type { WidgetConfig } from '../../hooks'
|
import type { WidgetConfig } from '../../hooks'
|
||||||
import './widget-styles.css'
|
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 {
|
interface ComfortIndexWidgetProps {
|
||||||
config: WidgetConfig
|
config: WidgetConfig
|
||||||
}
|
}
|
||||||
@@ -85,7 +138,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
|||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
<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 justify-between p-2 bg-base-200 rounded">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm">🌡️</span>
|
<ThermometerIcon className="w-4 h-4" />
|
||||||
<span className="truncate">Temperature</span>
|
<span className="truncate">Temperature</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`font-bold ${getScoreColor(data.components.temperature)}`}>
|
<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 justify-between p-2 bg-base-200 rounded">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm">💧</span>
|
<DropletIcon className="w-4 h-4" />
|
||||||
<span className="truncate">Humidity</span>
|
<span className="truncate">Humidity</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`font-bold ${getScoreColor(data.components.humidity)}`}>
|
<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 justify-between p-2 bg-base-200 rounded">
|
||||||
<div className="flex items-center gap-1.5">
|
<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>
|
<span className="truncate">Air Quality</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`font-bold ${getScoreColor(data.components.air_quality)}`}>
|
<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 justify-between p-2 bg-base-200 rounded">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm">🔊</span>
|
<AcousticIcon className="w-4 h-4" />
|
||||||
<span className="truncate">Acoustic</span>
|
<span className="truncate">Acoustic</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`font-bold ${getScoreColor(data.components.acoustic)}`}>
|
<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 justify-between p-2 bg-base-200 rounded col-span-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm">💡</span>
|
<LightIcon className="w-4 h-4" />
|
||||||
<span className="truncate">Lighting</span>
|
<span className="truncate">Lighting</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`font-bold ${getScoreColor(data.components.light)}`}>
|
<span className={`font-bold ${getScoreColor(data.components.light)}`}>
|
||||||
@@ -137,7 +190,10 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
|||||||
{/* Suggestions */}
|
{/* Suggestions */}
|
||||||
{data.suggestions.length > 0 && (
|
{data.suggestions.length > 0 && (
|
||||||
<div className="mt-auto">
|
<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">
|
<div className="space-y-1 max-h-20 overflow-y-auto">
|
||||||
{data.suggestions.map((suggestion, i) => (
|
{data.suggestions.map((suggestion, i) => (
|
||||||
<div key={i} className="text-xs bg-warning/10 p-1 rounded border-l-2 border-warning">
|
<div key={i} className="text-xs bg-warning/10 p-1 rounded border-l-2 border-warning">
|
||||||
|
|||||||
266
frontend/src/components/widgets/DailyBriefingWidget.tsx
Normal file
266
frontend/src/components/widgets/DailyBriefingWidget.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { wellnessApi } from '../../api'
|
||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
import './widget-styles.css'
|
||||||
|
|
||||||
|
interface DailyBriefingWidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BriefingData {
|
||||||
|
status_emoji: string
|
||||||
|
status_line: string
|
||||||
|
insights: string[]
|
||||||
|
recommendations: string[]
|
||||||
|
briefing_type: string
|
||||||
|
generated_at: string
|
||||||
|
context: {
|
||||||
|
indoor: Record<string, number> | null
|
||||||
|
outdoor: Record<string, number | string | null> | null
|
||||||
|
health: Record<string, number> | null
|
||||||
|
calendar_event_count: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CalendarIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const BuildingIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ClipboardIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const LightbulbIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const StatusIcon = ({ status }: { status: string }) => {
|
||||||
|
if (status.includes('good') || status.includes('great') || status.includes('optimal')) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status.includes('warning') || status.includes('moderate') || status.includes('attention')) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRIEFING_TYPES = [
|
||||||
|
{ value: 'schedule', label: 'Schedule', Icon: CalendarIcon, description: 'Calendar & activity focus' },
|
||||||
|
{ value: 'environment', label: 'Environment', Icon: BuildingIcon, description: 'Workspace conditions' },
|
||||||
|
{ value: 'full', label: 'Full', Icon: ClipboardIcon, description: 'Complete overview' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type BriefingType = typeof BRIEFING_TYPES[number]['value']
|
||||||
|
|
||||||
|
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function DailyBriefingWidget({ config }: DailyBriefingWidgetProps) {
|
||||||
|
const briefingConfig = config.briefing
|
||||||
|
const [briefingType, setBriefingType] = useState<BriefingType>(
|
||||||
|
(briefingConfig?.briefingType as BriefingType) || 'full'
|
||||||
|
)
|
||||||
|
const [briefingData, setBriefingData] = useState<BriefingData | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const city = config.visualization?.city || briefingConfig?.city || 'Skopje'
|
||||||
|
const healthDeviceId = config.deviceIds?.[0] || briefingConfig?.healthDeviceId
|
||||||
|
const calendarUrl = briefingConfig?.calendarUrl
|
||||||
|
|
||||||
|
const generateBriefing = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await wellnessApi.getDailyBriefing({
|
||||||
|
briefing_type: briefingType,
|
||||||
|
city,
|
||||||
|
health_device_id: healthDeviceId,
|
||||||
|
calendar_url: calendarUrl,
|
||||||
|
calendar_range_hours: briefingConfig?.calendarRangeHours || 24,
|
||||||
|
})
|
||||||
|
|
||||||
|
setBriefingData(response.data)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to generate briefing'
|
||||||
|
setError(message)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [briefingType, city, healthDeviceId, calendarUrl, briefingConfig?.calendarRangeHours])
|
||||||
|
|
||||||
|
const getBriefingTypeInfo = (type: BriefingType) => {
|
||||||
|
return BRIEFING_TYPES.find(t => t.value === type) || BRIEFING_TYPES[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
const CurrentIcon = getBriefingTypeInfo(briefingType).Icon
|
||||||
|
|
||||||
|
// No config state - show setup message
|
||||||
|
if (!city) {
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<h2 className="card-title text-sm">{config.title || 'Daily Briefing'}</h2>
|
||||||
|
<p className="text-sm opacity-70">
|
||||||
|
Configure a city to generate briefings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full flex flex-col">
|
||||||
|
<div className="card-body p-3 flex-1 flex flex-col gap-2 min-h-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide opacity-60 flex items-center gap-1">
|
||||||
|
<CurrentIcon /> {getBriefingTypeInfo(briefingType).label} Briefing
|
||||||
|
</p>
|
||||||
|
<h2 className="card-title text-sm leading-tight">{config.title || 'Daily Briefing'}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Briefing Type Selector */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{BRIEFING_TYPES.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type.value}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-xs flex-1 gap-1 ${
|
||||||
|
briefingType === type.value ? 'btn-primary' : 'btn-outline'
|
||||||
|
}`}
|
||||||
|
onClick={() => setBriefingType(type.value)}
|
||||||
|
title={type.description}
|
||||||
|
>
|
||||||
|
<type.Icon /> {type.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generate Button or Content */}
|
||||||
|
{!briefingData && !isLoading && (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-3">
|
||||||
|
<p className="text-sm opacity-60 text-center">
|
||||||
|
Get AI-powered insights for your day
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={generateBriefing}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Generate Briefing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-2">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
<p className="text-sm opacity-60">Analyzing your environment...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && !isLoading && (
|
||||||
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
|
<div className="alert alert-error text-xs">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline"
|
||||||
|
onClick={generateBriefing}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Briefing Content */}
|
||||||
|
{briefingData && !isLoading && (
|
||||||
|
<div className="flex-1 flex flex-col gap-2 overflow-y-auto">
|
||||||
|
{/* Status Line */}
|
||||||
|
<div className="bg-base-200 rounded-lg p-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusIcon status={briefingData.status_line.toLowerCase()} />
|
||||||
|
<p className="text-sm font-medium">{briefingData.status_line}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Insights */}
|
||||||
|
{briefingData.insights.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{briefingData.insights.map((insight, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 text-sm">
|
||||||
|
<span className="text-primary mt-0.5">•</span>
|
||||||
|
<span className="opacity-90">{insight}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recommendations */}
|
||||||
|
{briefingData.recommendations.length > 0 && (
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2 mt-auto">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<LightbulbIcon />
|
||||||
|
<div className="space-y-1">
|
||||||
|
{briefingData.recommendations.map((rec, i) => (
|
||||||
|
<p key={i} className="text-sm opacity-90">{rec}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer with timestamp and refresh */}
|
||||||
|
<div className="flex items-center justify-between pt-1 border-t border-base-200 mt-auto">
|
||||||
|
<span className="text-[10px] opacity-50">
|
||||||
|
Generated {timeFormatter.format(new Date(briefingData.generated_at))}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-xs btn-ghost"
|
||||||
|
onClick={generateBriefing}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
178
frontend/src/components/widgets/HealthStatsWidget.tsx
Normal file
178
frontend/src/components/widgets/HealthStatsWidget.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { wellnessApi } from '../../api'
|
||||||
|
import { useWellnessState } from '../../hooks/useWellnessState'
|
||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
import './widget-styles.css'
|
||||||
|
|
||||||
|
interface HealthStatsWidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HealthStatsWidget({ config }: HealthStatsWidgetProps) {
|
||||||
|
const { healthDeviceId, city } = useWellnessState()
|
||||||
|
|
||||||
|
// Use device from config or shared state
|
||||||
|
const deviceId = config.deviceIds[0] || healthDeviceId
|
||||||
|
const widgetCity = (config.visualization as Record<string, unknown>)?.city as string || city
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['health-insights', deviceId, widgetCity],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!deviceId) {
|
||||||
|
throw new Error('Device ID is required')
|
||||||
|
}
|
||||||
|
const response = await wellnessApi.getHealthInsights(deviceId, widgetCity || undefined)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
refetchInterval: 60000, // Refresh every minute
|
||||||
|
enabled: !!deviceId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body flex items-center justify-center">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body flex items-center justify-center">
|
||||||
|
<div className="alert alert-error text-xs">
|
||||||
|
<span>
|
||||||
|
{error instanceof Error ? error.message : 'Failed to load health data'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!deviceId && (
|
||||||
|
<p className="text-xs text-base-content/60 mt-2">Select a health device</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
|
const StepIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const FireIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const HeartIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const CheckIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const WarningIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
label: 'Steps',
|
||||||
|
value: data.health_metrics.steps,
|
||||||
|
unit: '',
|
||||||
|
icon: <StepIcon />,
|
||||||
|
color: 'text-primary'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Calories',
|
||||||
|
value: data.health_metrics.active_calories,
|
||||||
|
unit: 'kcal',
|
||||||
|
icon: <FireIcon />,
|
||||||
|
color: 'text-secondary'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Heart Rate',
|
||||||
|
value: data.health_metrics.heart_rate,
|
||||||
|
unit: 'bpm',
|
||||||
|
icon: <HeartIcon />,
|
||||||
|
color: 'text-error'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Find insights for each stat
|
||||||
|
const getInsightForMetric = (metric: string) => {
|
||||||
|
return data.insights.find(i => i.metric === metric)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full flex flex-col">
|
||||||
|
<div className="card-body p-3 flex-1 min-h-0">
|
||||||
|
<h2 className="card-title text-sm mb-3">{config.title}</h2>
|
||||||
|
|
||||||
|
{/* Health Stats */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stats.map((stat, i) => {
|
||||||
|
const insight = getInsightForMetric(stat.label)
|
||||||
|
const hasValue = stat.value !== null && stat.value !== undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={stat.color}>{stat.icon}</span>
|
||||||
|
<span className="text-sm opacity-70">{stat.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className={`text-xl font-bold ${stat.color}`}>
|
||||||
|
{hasValue ? Math.round(Number(stat.value)).toLocaleString() : '—'}
|
||||||
|
</span>
|
||||||
|
{hasValue && <span className="text-xs opacity-50">{stat.unit}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context/Correlation */}
|
||||||
|
{insight && (
|
||||||
|
<div className="text-xs space-y-0.5 ml-7">
|
||||||
|
{insight.context && (
|
||||||
|
<div className="opacity-70">{insight.context}</div>
|
||||||
|
)}
|
||||||
|
{insight.correlation && (
|
||||||
|
<div className="text-warning opacity-80 flex items-center gap-1">
|
||||||
|
<WarningIcon />
|
||||||
|
{insight.correlation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recommendations */}
|
||||||
|
{data.recommendations.length > 0 && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-base-300">
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="flex items-start gap-1.5">
|
||||||
|
<span className="text-success"><CheckIcon /></span>
|
||||||
|
<span className="opacity-80">{data.recommendations[0]}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo, memo } from 'react'
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@@ -17,15 +17,39 @@ interface LineChartWidgetProps {
|
|||||||
config: WidgetConfig
|
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
|
// Helper component to fetch data for a single metric
|
||||||
function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: WidgetConfig['timeframe']) {
|
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({
|
const metric1 = useTelemetrySeries({
|
||||||
deviceId,
|
deviceId,
|
||||||
metric: metricIds[0] || '',
|
metric: metricIds[0] || '',
|
||||||
hours: timeframe.hours,
|
hours: timeframe.hours,
|
||||||
startTime: timeframe.startTime,
|
startTime: timeframe.startTime,
|
||||||
endTime: timeframe.endTime,
|
endTime: timeframe.endTime,
|
||||||
|
limit: 500, // Limit data points for performance
|
||||||
})
|
})
|
||||||
|
|
||||||
const metric2 = useTelemetrySeries({
|
const metric2 = useTelemetrySeries({
|
||||||
@@ -34,6 +58,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi
|
|||||||
hours: timeframe.hours,
|
hours: timeframe.hours,
|
||||||
startTime: timeframe.startTime,
|
startTime: timeframe.startTime,
|
||||||
endTime: timeframe.endTime,
|
endTime: timeframe.endTime,
|
||||||
|
limit: 500,
|
||||||
|
enabled: metricIds.length > 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
const metric3 = useTelemetrySeries({
|
const metric3 = useTelemetrySeries({
|
||||||
@@ -42,6 +68,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi
|
|||||||
hours: timeframe.hours,
|
hours: timeframe.hours,
|
||||||
startTime: timeframe.startTime,
|
startTime: timeframe.startTime,
|
||||||
endTime: timeframe.endTime,
|
endTime: timeframe.endTime,
|
||||||
|
limit: 500,
|
||||||
|
enabled: metricIds.length > 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
const metric4 = useTelemetrySeries({
|
const metric4 = useTelemetrySeries({
|
||||||
@@ -50,6 +78,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi
|
|||||||
hours: timeframe.hours,
|
hours: timeframe.hours,
|
||||||
startTime: timeframe.startTime,
|
startTime: timeframe.startTime,
|
||||||
endTime: timeframe.endTime,
|
endTime: timeframe.endTime,
|
||||||
|
limit: 500,
|
||||||
|
enabled: metricIds.length > 3,
|
||||||
})
|
})
|
||||||
|
|
||||||
const metric5 = useTelemetrySeries({
|
const metric5 = useTelemetrySeries({
|
||||||
@@ -58,6 +88,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi
|
|||||||
hours: timeframe.hours,
|
hours: timeframe.hours,
|
||||||
startTime: timeframe.startTime,
|
startTime: timeframe.startTime,
|
||||||
endTime: timeframe.endTime,
|
endTime: timeframe.endTime,
|
||||||
|
limit: 500,
|
||||||
|
enabled: metricIds.length > 4,
|
||||||
})
|
})
|
||||||
|
|
||||||
const queries = [metric1, metric2, metric3, metric4, metric5].slice(0, metricIds.length)
|
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 }
|
return { queries, metricIds }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
function LineChartWidget({ config }: LineChartWidgetProps) {
|
||||||
const { deviceIds, metricIds, timeframe, visualization } = config
|
const { deviceIds, metricIds, timeframe, visualization } = config
|
||||||
const deviceId = deviceIds[0]
|
const deviceId = deviceIds[0]
|
||||||
|
|
||||||
@@ -74,64 +106,83 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
|||||||
const isLoading = queries.some((q) => q.isLoading)
|
const isLoading = queries.some((q) => q.isLoading)
|
||||||
const error = queries.find((q) => q.error)?.error
|
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(() => {
|
const chartData = useMemo(() => {
|
||||||
if (queries.length === 0 || !queries[0]?.data || queries[0].data.length === 0) return []
|
if (queries.length === 0 || !queries[0]?.data || queries[0].data.length === 0) return []
|
||||||
|
|
||||||
// Create a map of time -> { time, fullDateTime, metric1, metric2, ... }
|
// Limit total data points for performance (max 300 points)
|
||||||
const timeMap = new Map<string, Record<string, number | string>>()
|
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) => {
|
queries.forEach((query, index) => {
|
||||||
const metric = metricIds[index]
|
const metric = metricIds[index]
|
||||||
if (!query.data) return
|
if (!query.data || query.data.length === 0) return
|
||||||
|
|
||||||
query.data.forEach((point) => {
|
// Process data points efficiently (with downsampling if needed)
|
||||||
const date = new Date(point.time)
|
query.data.forEach((point, pointIndex) => {
|
||||||
|
// Skip points if downsampling
|
||||||
|
if (shouldDownsample && pointIndex % step !== 0) return
|
||||||
|
|
||||||
// Short time for X-axis display (just time)
|
const timestamp = new Date(point.time).getTime()
|
||||||
const timeStr = date.toLocaleString('en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Full date/time for tooltip
|
if (!timeMap.has(timestamp)) {
|
||||||
const fullDateTime = date.toLocaleString('en-US', {
|
const date = new Date(timestamp)
|
||||||
month: 'short',
|
timeMap.set(timestamp, {
|
||||||
day: 'numeric',
|
time: formatTime(date),
|
||||||
year: 'numeric',
|
fullDateTime: formatDateTime(date),
|
||||||
hour: '2-digit',
|
timestamp
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
|
|
||||||
const key = date.getTime().toString()
|
|
||||||
|
|
||||||
if (!timeMap.has(key)) {
|
|
||||||
timeMap.set(key, {
|
|
||||||
time: timeStr,
|
|
||||||
fullDateTime: fullDateTime,
|
|
||||||
timestamp: date.getTime()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = timeMap.get(key)!
|
const entry = timeMap.get(timestamp)!
|
||||||
entry[metric] = point.value
|
entry[metric] = point.value
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sort by timestamp
|
// Sort by timestamp and convert to array
|
||||||
return Array.from(timeMap.values()).sort((a, b) => {
|
const result = Array.from(timeMap.values()).sort((a, b) => {
|
||||||
return (a.timestamp as number) - (b.timestamp as number)
|
return (a.timestamp as number) - (b.timestamp as number)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
}, [queries, metricIds])
|
}, [queries, metricIds])
|
||||||
|
|
||||||
const colors = visualization?.colors || [
|
// Memoize colors to avoid recreating array
|
||||||
|
const colors = useMemo(() =>
|
||||||
|
visualization?.colors || [
|
||||||
'#3b82f6', // blue
|
'#3b82f6', // blue
|
||||||
'#10b981', // green
|
'#10b981', // green
|
||||||
'#f59e0b', // amber
|
'#f59e0b', // amber
|
||||||
'#ef4444', // red
|
'#ef4444', // red
|
||||||
'#8b5cf6', // purple
|
'#8b5cf6', // purple
|
||||||
'#ec4899', // pink
|
'#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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -190,7 +241,11 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
|||||||
{config.title || metricIds.map(formatMetricName).join(' & ')}
|
{config.title || metricIds.map(formatMetricName).join(' & ')}
|
||||||
</h3>
|
</h3>
|
||||||
<ResponsiveContainer width="100%" height={visualization?.height || 280}>
|
<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 && (
|
{visualization?.showGrid !== false && (
|
||||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||||
)}
|
)}
|
||||||
@@ -200,6 +255,7 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
|||||||
angle={-45}
|
angle={-45}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
height={50}
|
height={50}
|
||||||
|
interval="preserveStartEnd" // Reduce number of ticks
|
||||||
/>
|
/>
|
||||||
<YAxis tick={{ fontSize: 12 }} />
|
<YAxis tick={{ fontSize: 12 }} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -216,22 +272,13 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
|||||||
formatter={(value: number) => [value.toFixed(2)]}
|
formatter={(value: number) => [value.toFixed(2)]}
|
||||||
/>
|
/>
|
||||||
{visualization?.showLegend !== false && <Legend />}
|
{visualization?.showLegend !== false && <Legend />}
|
||||||
{metricIds.map((metric, index) => (
|
{lines}
|
||||||
<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)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Memoize the component to prevent unnecessary re-renders
|
||||||
|
export default memo(LineChartWidget)
|
||||||
|
|||||||
162
frontend/src/components/widgets/RunSuitabilityWidget.tsx
Normal file
162
frontend/src/components/widgets/RunSuitabilityWidget.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { wellnessApi } from '../../api'
|
||||||
|
import { useWellnessState } from '../../hooks/useWellnessState'
|
||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
import './widget-styles.css'
|
||||||
|
|
||||||
|
const CheckIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const StepIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
interface RunSuitabilityWidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RunSuitabilityWidget({ config }: RunSuitabilityWidgetProps) {
|
||||||
|
const { healthDeviceId, city } = useWellnessState()
|
||||||
|
|
||||||
|
// Use device from config or shared state
|
||||||
|
const deviceId = config.deviceIds[0] || healthDeviceId
|
||||||
|
const widgetCity = (config.visualization as Record<string, unknown>)?.city as string || city
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['run-suitability', deviceId, widgetCity],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!deviceId || !widgetCity) {
|
||||||
|
throw new Error('Device ID and city are required')
|
||||||
|
}
|
||||||
|
const response = await wellnessApi.getRunSuitability(deviceId, widgetCity)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
refetchInterval: 300000, // Refresh every 5 minutes
|
||||||
|
enabled: !!deviceId && !!widgetCity,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body flex items-center justify-center">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body flex flex-col items-center justify-center text-center gap-2">
|
||||||
|
<div className="alert alert-error text-xs">
|
||||||
|
<span>
|
||||||
|
{error instanceof Error ? error.message : 'No data available'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(!deviceId || !widgetCity) && (
|
||||||
|
<p className="text-xs text-base-content/60">
|
||||||
|
{!deviceId && 'Select a health device'}
|
||||||
|
{!deviceId && !widgetCity && ' and '}
|
||||||
|
{!widgetCity && 'Select a city'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'GO': return 'text-success'
|
||||||
|
case 'MODERATE': return 'text-warning'
|
||||||
|
case 'NO': return 'text-error'
|
||||||
|
default: return 'text-base-content'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBgColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'GO': return 'bg-success/10 border-success/20'
|
||||||
|
case 'MODERATE': return 'bg-warning/10 border-warning/20'
|
||||||
|
case 'NO': return 'bg-error/10 border-error/20'
|
||||||
|
default: return 'bg-base-200'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full flex flex-col">
|
||||||
|
<div className="card-body p-3 flex flex-col gap-2 flex-1 min-h-0">
|
||||||
|
<h2 className="card-title text-sm mb-2">{config.title}</h2>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div className="flex flex-col items-center justify-center text-center gap-2">
|
||||||
|
<div className={`w-20 h-20 rounded-full flex items-center justify-center border-4 ${getStatusColor(data.status)} ${getBgColor(data.status)}`}>
|
||||||
|
<span className={`text-2xl font-black ${getStatusColor(data.status)}`}>
|
||||||
|
{data.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary Reason */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium text-sm">{data.primary_reason}</p>
|
||||||
|
|
||||||
|
{/* Score Breakdown */}
|
||||||
|
<div className="flex gap-2 text-xs text-base-content/60 justify-center">
|
||||||
|
<span>Weather: {data.scores.weather.toFixed(0)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Air: {data.scores.air_quality.toFixed(0)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Health: {data.scores.health.toFixed(0)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detailed Insights */}
|
||||||
|
{data.detailed_insights.length > 0 && (
|
||||||
|
<div className="space-y-1 mt-1">
|
||||||
|
<div className="text-xs font-semibold opacity-70">Conditions:</div>
|
||||||
|
<ul className="text-xs space-y-0.5">
|
||||||
|
{data.detailed_insights.slice(0, 2).map((insight, i) => (
|
||||||
|
<li key={i} className="opacity-80">• {insight}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time Recommendations */}
|
||||||
|
{data.time_recommendations.length > 0 && (
|
||||||
|
<div className="space-y-1 mt-1">
|
||||||
|
<div className="text-xs font-semibold opacity-70">Best Time:</div>
|
||||||
|
<div className="text-xs opacity-80">• {data.time_recommendations[0]}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suggestions */}
|
||||||
|
{data.suggestions.length > 0 && (
|
||||||
|
<div className="mt-1 pt-1 border-t border-base-300">
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="flex items-start gap-1.5">
|
||||||
|
<span className="text-success"><CheckIcon /></span>
|
||||||
|
<span className="opacity-80">{data.suggestions[0]}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Health Stats */}
|
||||||
|
<div className="text-xs text-base-content/60 flex gap-2 justify-center pt-1 border-t border-base-300 flex-shrink-0 mt-auto">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<StepIcon />
|
||||||
|
{data.health_data.steps.toLocaleString()} steps
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,118 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import type { WidgetConfig } from '../../hooks'
|
import type { WidgetConfig } from '../../hooks'
|
||||||
import { weatherApi } from '../../api'
|
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 {
|
interface WeatherWidgetProps {
|
||||||
config: WidgetConfig
|
config: WidgetConfig
|
||||||
}
|
}
|
||||||
@@ -45,19 +156,18 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
|||||||
|
|
||||||
if (!weather) return null
|
if (!weather) return null
|
||||||
|
|
||||||
// Weather code to icon mapping
|
|
||||||
const getWeatherIcon = (code: number) => {
|
const getWeatherIcon = (code: number) => {
|
||||||
if (code === 0 || code === 1) return '☀️' // Clear/Mainly clear
|
if (code === 0 || code === 1) return <SunIcon className="w-16 h-16 text-warning" />
|
||||||
if (code === 2) return '⛅' // Partly cloudy
|
if (code === 2) return <PartlyCloudyIcon className="w-16 h-16 text-primary" />
|
||||||
if (code === 3) return '☁️' // Overcast
|
if (code === 3) return <CloudIcon className="w-16 h-16 text-primary" />
|
||||||
if (code >= 45 && code <= 48) return '🌫️' // Fog
|
if (code >= 45 && code <= 48) return <FogIcon className="w-16 h-16 text-primary" />
|
||||||
if (code >= 51 && code <= 55) return '🌦️' // Drizzle
|
if (code >= 51 && code <= 55) return <RainIcon className="w-16 h-16 text-primary" />
|
||||||
if (code >= 61 && code <= 65) return '🌧️' // Rain
|
if (code >= 61 && code <= 65) return <RainIcon className="w-16 h-16 text-primary" />
|
||||||
if (code >= 71 && code <= 77) return '🌨️' // Snow
|
if (code >= 71 && code <= 77) return <SnowIcon className="w-16 h-16 text-primary" />
|
||||||
if (code >= 80 && code <= 82) return '🌧️' // Rain showers
|
if (code >= 80 && code <= 82) return <RainIcon className="w-16 h-16 text-primary" />
|
||||||
if (code >= 85 && code <= 86) return '🌨️' // Snow showers
|
if (code >= 85 && code <= 86) return <SnowIcon className="w-16 h-16 text-primary" />
|
||||||
if (code >= 95) return '⛈️' // Thunderstorm
|
if (code >= 95) return <ThunderIcon className="w-16 h-16 text-primary" />
|
||||||
return '🌡️'
|
return <ThermometerIcon className="w-16 h-16 text-primary" />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -66,7 +176,7 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
|||||||
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
||||||
<div className="flex flex-col items-center justify-center flex-1">
|
<div className="flex flex-col items-center justify-center flex-1">
|
||||||
{/* Weather Icon */}
|
{/* 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 */}
|
{/* Temperature */}
|
||||||
<div className="text-3xl font-bold">{weather.temperature.toFixed(1)}°C</div>
|
<div className="text-3xl font-bold">{weather.temperature.toFixed(1)}°C</div>
|
||||||
@@ -82,20 +192,20 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
|||||||
{/* Additional Info */}
|
{/* Additional Info */}
|
||||||
<div className="grid grid-cols-2 gap-2 mt-2 w-full text-xs">
|
<div className="grid grid-cols-2 gap-2 mt-2 w-full text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="opacity-60">💧</span>
|
<DropletIcon className="w-4 h-4 opacity-70" />
|
||||||
<span>{weather.humidity}%</span>
|
<span>{weather.humidity}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span>{weather.wind_speed.toFixed(1)} km/h</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span>{weather.cloud_cover}%</span>
|
||||||
</div>
|
</div>
|
||||||
{weather.precipitation > 0 && (
|
{weather.precipitation > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span>{weather.precipitation} mm</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo, useEffect, useRef } from 'react'
|
||||||
import type { WidgetConfig } from '../../hooks'
|
import type { WidgetConfig } from '../../hooks'
|
||||||
import { widgetRegistry } from './registry'
|
import { widgetRegistry } from './registry'
|
||||||
|
|
||||||
@@ -5,10 +6,34 @@ interface WidgetContainerProps {
|
|||||||
config: WidgetConfig
|
config: WidgetConfig
|
||||||
onRemove?: (id: string) => void
|
onRemove?: (id: string) => void
|
||||||
onEdit?: (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 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) {
|
if (!WidgetComponent) {
|
||||||
return (
|
return (
|
||||||
@@ -21,10 +46,9 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative group h-full">
|
<div className="relative group h-full w-full">
|
||||||
{/* Drag handle and actions */}
|
<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="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 shadow-md text-xs border border-base-300 pointer-events-auto">
|
||||||
<div className="drag-handle cursor-move flex items-center gap-1 px-2 py-1 rounded bg-base-100/80 text-xs">
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
@@ -41,11 +65,15 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont
|
|||||||
</svg>
|
</svg>
|
||||||
Drag
|
Drag
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1 pointer-events-auto">
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-xs btn-circle btn-ghost bg-base-100/80"
|
type="button"
|
||||||
onClick={() => onEdit(config.id)}
|
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"
|
title="Edit widget"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -66,8 +94,12 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont
|
|||||||
)}
|
)}
|
||||||
{onRemove && (
|
{onRemove && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-xs btn-circle btn-ghost bg-base-100/80"
|
type="button"
|
||||||
onClick={() => onRemove(config.id)}
|
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"
|
title="Remove widget"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -89,10 +121,12 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actual widget */}
|
{/* Allow overlay to float without reserving layout space */}
|
||||||
<div className="h-full">
|
<div className="w-full" ref={contentRef}>
|
||||||
<WidgetComponent config={config} />
|
<WidgetComponent config={config} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(WidgetContainer)
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export { default as LineChartWidget } from './LineChartWidget'
|
|||||||
export { default as StatWidget } from './StatWidget'
|
export { default as StatWidget } from './StatWidget'
|
||||||
export { default as GaugeWidget } from './GaugeWidget'
|
export { default as GaugeWidget } from './GaugeWidget'
|
||||||
export { default as AiInsightWidget } from './AiInsightWidget'
|
export { default as AiInsightWidget } from './AiInsightWidget'
|
||||||
|
export { default as CalendarWidget } from './CalendarWidget'
|
||||||
export { widgetRegistry } from './registry'
|
export { widgetRegistry } from './registry'
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import AiInsightWidget from './AiInsightWidget'
|
|||||||
import AirQualityWidget from './AirQualityWidget'
|
import AirQualityWidget from './AirQualityWidget'
|
||||||
import WeatherWidget from './WeatherWidget'
|
import WeatherWidget from './WeatherWidget'
|
||||||
import ComfortIndexWidget from './ComfortIndexWidget'
|
import ComfortIndexWidget from './ComfortIndexWidget'
|
||||||
|
import RunSuitabilityWidget from './RunSuitabilityWidget'
|
||||||
|
import HealthStatsWidget from './HealthStatsWidget'
|
||||||
|
import CalendarWidget from './CalendarWidget'
|
||||||
|
import DailyBriefingWidget from './DailyBriefingWidget'
|
||||||
|
|
||||||
interface WidgetProps {
|
interface WidgetProps {
|
||||||
config: WidgetConfig
|
config: WidgetConfig
|
||||||
@@ -22,4 +26,8 @@ export const widgetRegistry: Record<WidgetType, ComponentType<WidgetProps>> = {
|
|||||||
'air-quality': AirQualityWidget,
|
'air-quality': AirQualityWidget,
|
||||||
'weather': WeatherWidget,
|
'weather': WeatherWidget,
|
||||||
'comfort-index': ComfortIndexWidget,
|
'comfort-index': ComfortIndexWidget,
|
||||||
|
'run-suitability': RunSuitabilityWidget,
|
||||||
|
'health-stats': HealthStatsWidget,
|
||||||
|
'calendar': CalendarWidget,
|
||||||
|
'daily-briefing': DailyBriefingWidget,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
/* Reduce card body padding for widgets - default is 2rem (32px), we want less */
|
/* Reduce card body padding for widgets - default is 2rem (32px), we want less */
|
||||||
.widget-card .card-body {
|
.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 */
|
/* 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);
|
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 {
|
.widget-card {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-card .card-body {
|
.widget-card .card-body {
|
||||||
@@ -23,3 +24,35 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0; /* Allow flex children to shrink */
|
min-height: 0; /* Allow flex children to shrink */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gridstack alignment */
|
||||||
|
.grid-stack-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-stack-item-content {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gridstack drag handle */
|
||||||
|
.grid-stack-item .drag-handle {
|
||||||
|
cursor: move;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure gridstack items are interactive */
|
||||||
|
.grid-stack-item {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gridstack resize handles */
|
||||||
|
.grid-stack-item .ui-resizable-handle {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { dashboardLayoutApi } from '../api'
|
||||||
|
|
||||||
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather' | 'comfort-index'
|
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather' | 'comfort-index' | 'run-suitability' | 'health-stats' | 'calendar' | 'daily-briefing'
|
||||||
|
|
||||||
export interface WidgetConfig {
|
export interface WidgetConfig {
|
||||||
id: string
|
id: string
|
||||||
@@ -20,6 +21,17 @@ export interface WidgetConfig {
|
|||||||
height?: number
|
height?: number
|
||||||
city?: string
|
city?: string
|
||||||
}
|
}
|
||||||
|
calendar?: {
|
||||||
|
icalUrl: string
|
||||||
|
timeRangeHours?: number
|
||||||
|
}
|
||||||
|
briefing?: {
|
||||||
|
briefingType: 'schedule' | 'environment' | 'full'
|
||||||
|
city: string
|
||||||
|
healthDeviceId?: string
|
||||||
|
calendarUrl?: string
|
||||||
|
calendarRangeHours?: number
|
||||||
|
}
|
||||||
position?: {
|
position?: {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
@@ -43,30 +55,108 @@ const DEFAULT_CONFIG: DashboardConfig = {
|
|||||||
const STORAGE_KEY = 'iot-dashboard-config'
|
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() {
|
export function useDashboardConfig() {
|
||||||
const [config, setConfig] = useState<DashboardConfig>(() => {
|
const [config, setConfig] = useState<DashboardConfig>(DEFAULT_CONFIG)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [layoutId, setLayoutId] = useState<number | null>(null)
|
||||||
|
const isInitialLoadRef = useRef(true)
|
||||||
|
|
||||||
|
// Load config from backend or localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
const loadConfig = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
|
// 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)
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
if (stored) {
|
if (stored) {
|
||||||
return JSON.parse(stored) as DashboardConfig
|
const parsed = JSON.parse(stored) as DashboardConfig
|
||||||
|
setConfig(parsed)
|
||||||
|
// Save to backend
|
||||||
|
try {
|
||||||
|
const response = await dashboardLayoutApi.create({
|
||||||
|
name: 'default',
|
||||||
|
config: parsed,
|
||||||
|
is_default: true,
|
||||||
|
})
|
||||||
|
setLayoutId(response.data.id)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save to backend:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load dashboard config:', error)
|
console.error('Failed to load dashboard config:', error)
|
||||||
|
// Fallback to localStorage
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored) {
|
||||||
|
setConfig(JSON.parse(stored) as DashboardConfig)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load from localStorage:', e)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
isInitialLoadRef.current = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return DEFAULT_CONFIG
|
|
||||||
})
|
|
||||||
|
|
||||||
// Persist to localStorage whenever config changes
|
loadConfig()
|
||||||
useEffect(() => {
|
}, [])
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||||
} catch (error) {
|
} 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])
|
}, [config])
|
||||||
|
|
||||||
|
|
||||||
const addWidget = (widget: WidgetConfig) => {
|
const addWidget = (widget: WidgetConfig) => {
|
||||||
setConfig((prev) => ({
|
setConfig((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -124,5 +214,7 @@ export function useDashboardConfig() {
|
|||||||
resetConfig,
|
resetConfig,
|
||||||
exportConfig,
|
exportConfig,
|
||||||
importConfig,
|
importConfig,
|
||||||
|
saveConfig,
|
||||||
|
isLoading,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
155
frontend/src/hooks/useGridstack.ts
Normal file
155
frontend/src/hooks/useGridstack.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { GridStack } from 'gridstack'
|
||||||
|
import 'gridstack/dist/gridstack.min.css'
|
||||||
|
|
||||||
|
// Define the widget type based on gridstack.js structure
|
||||||
|
export type GridStackWidget = {
|
||||||
|
id?: string | number
|
||||||
|
x?: number
|
||||||
|
y?: number
|
||||||
|
w?: number
|
||||||
|
h?: number
|
||||||
|
minW?: number
|
||||||
|
minH?: number
|
||||||
|
maxW?: number
|
||||||
|
maxH?: number
|
||||||
|
noResize?: boolean
|
||||||
|
noMove?: boolean
|
||||||
|
locked?: boolean
|
||||||
|
[key: string]: any // Allow additional properties
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseGridstackOptions {
|
||||||
|
columns?: number
|
||||||
|
cellHeight?: number
|
||||||
|
margin?: number
|
||||||
|
minRow?: number
|
||||||
|
onLayoutChange?: (items: GridStackWidget[]) => void
|
||||||
|
disableResize?: boolean
|
||||||
|
disableDrag?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGridstack(options: UseGridstackOptions = {}) {
|
||||||
|
const gridRef = useRef<HTMLDivElement>(null)
|
||||||
|
const gridInstanceRef = useRef<GridStack | null>(null)
|
||||||
|
const {
|
||||||
|
columns = 4,
|
||||||
|
cellHeight = 150,
|
||||||
|
margin = 12,
|
||||||
|
minRow = 1,
|
||||||
|
onLayoutChange,
|
||||||
|
disableResize = false,
|
||||||
|
disableDrag = false,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
// Initialize gridstack
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gridRef.current) return
|
||||||
|
|
||||||
|
// Clean up existing instance
|
||||||
|
if (gridInstanceRef.current) {
|
||||||
|
gridInstanceRef.current.destroy(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new gridstack instance
|
||||||
|
// Gridstack will automatically detect and manage elements with data-gs-* attributes
|
||||||
|
const grid = GridStack.init(
|
||||||
|
{
|
||||||
|
column: columns,
|
||||||
|
cellHeight,
|
||||||
|
margin,
|
||||||
|
minRow,
|
||||||
|
resizable: {
|
||||||
|
handles: 'e, se, s, sw, w',
|
||||||
|
},
|
||||||
|
disableResize,
|
||||||
|
disableDrag,
|
||||||
|
float: false,
|
||||||
|
animate: true,
|
||||||
|
acceptWidgets: false,
|
||||||
|
// Removed handle option - entire widget is draggable for better UX
|
||||||
|
},
|
||||||
|
gridRef.current
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle layout change
|
||||||
|
if (onLayoutChange) {
|
||||||
|
grid.on('change', (event, items) => {
|
||||||
|
const serialized = grid.save(false) as GridStackWidget[]
|
||||||
|
onLayoutChange(serialized)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
gridInstanceRef.current = grid
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (gridInstanceRef.current) {
|
||||||
|
gridInstanceRef.current.destroy(false)
|
||||||
|
gridInstanceRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [columns, cellHeight, margin, minRow, disableResize, disableDrag, onLayoutChange])
|
||||||
|
|
||||||
|
// Convert existing elements to gridstack widgets
|
||||||
|
const makeWidgets = useCallback(() => {
|
||||||
|
if (gridInstanceRef.current && gridRef.current) {
|
||||||
|
const items = gridRef.current.querySelectorAll('.grid-stack-item:not(.ui-draggable)')
|
||||||
|
items.forEach((item) => {
|
||||||
|
gridInstanceRef.current!.makeWidget(item as HTMLElement)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load items into grid
|
||||||
|
const loadItems = useCallback((items: GridStackWidget[]) => {
|
||||||
|
if (gridInstanceRef.current) {
|
||||||
|
gridInstanceRef.current.load(items)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Add item to grid
|
||||||
|
const addItem = useCallback((item: GridStackWidget) => {
|
||||||
|
if (gridInstanceRef.current) {
|
||||||
|
gridInstanceRef.current.addWidget(item)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Remove item from grid
|
||||||
|
const removeItem = useCallback((id: string) => {
|
||||||
|
if (gridInstanceRef.current) {
|
||||||
|
const el = gridInstanceRef.current.el.querySelector(`[gs-id="${id}"]`)
|
||||||
|
if (el) {
|
||||||
|
gridInstanceRef.current.removeWidget(el as HTMLElement, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Update item
|
||||||
|
const updateItem = useCallback((id: string, updates: Partial<GridStackWidget>) => {
|
||||||
|
if (gridInstanceRef.current) {
|
||||||
|
const el = gridInstanceRef.current.el.querySelector(`[gs-id="${id}"]`)
|
||||||
|
if (el) {
|
||||||
|
gridInstanceRef.current.update(el as HTMLElement, updates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Get current layout
|
||||||
|
const getLayout = useCallback((): GridStackWidget[] => {
|
||||||
|
if (gridInstanceRef.current) {
|
||||||
|
return gridInstanceRef.current.save(false) as GridStackWidget[]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridRef,
|
||||||
|
makeWidgets,
|
||||||
|
loadItems,
|
||||||
|
addItem,
|
||||||
|
removeItem,
|
||||||
|
updateItem,
|
||||||
|
getLayout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -62,7 +62,8 @@ export function useTelemetrySeries({
|
|||||||
return response.data as Telemetry[]
|
return response.data as Telemetry[]
|
||||||
},
|
},
|
||||||
enabled,
|
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 {
|
return {
|
||||||
|
|||||||
40
frontend/src/hooks/useWellnessState.tsx
Normal file
40
frontend/src/hooks/useWellnessState.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { createContext, useContext, useState, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface WellnessState {
|
||||||
|
healthDeviceId: string | null
|
||||||
|
city: string
|
||||||
|
setHealthDeviceId: (id: string | null) => void
|
||||||
|
setCity: (city: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const WellnessStateContext = createContext<WellnessState | undefined>(undefined)
|
||||||
|
|
||||||
|
export function WellnessStateProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [healthDeviceId, setHealthDeviceId] = useState<string | null>(null)
|
||||||
|
const [city, setCity] = useState<string>('Skopje')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WellnessStateContext.Provider
|
||||||
|
value={{
|
||||||
|
healthDeviceId,
|
||||||
|
city,
|
||||||
|
setHealthDeviceId,
|
||||||
|
setCity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</WellnessStateContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWellnessState() {
|
||||||
|
const context = useContext(WellnessStateContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useWellnessState must be used within WellnessStateProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -8,27 +8,13 @@ export const apiClient = axios.create({
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'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
|
// Add response interceptor for error handling
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
// Basic error handling - can be extended if needed
|
||||||
// Handle unauthorized - redirect to login
|
|
||||||
localStorage.removeItem('access_token');
|
|
||||||
// window.location.href = '/login';
|
|
||||||
}
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import GridLayout from 'react-grid-layout'
|
import GridLayout from 'react-grid-layout'
|
||||||
import 'react-grid-layout/css/styles.css'
|
import 'react-grid-layout/css/styles.css'
|
||||||
import { useDashboardConfig } from '../hooks'
|
import { useDashboardConfig } from '../hooks'
|
||||||
@@ -6,18 +6,35 @@ import { WidgetContainer } from '../components/widgets'
|
|||||||
import AddWidgetModal from '../components/AddWidgetModal'
|
import AddWidgetModal from '../components/AddWidgetModal'
|
||||||
import EditWidgetModal from '../components/EditWidgetModal'
|
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() {
|
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 [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
const [editingWidget, setEditingWidget] = useState<string | null>(null)
|
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)
|
const gridContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Update grid width on resize
|
// Update grid width on resize
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateWidth = () => {
|
const updateWidth = () => {
|
||||||
if (gridContainerRef.current) {
|
if (gridContainerRef.current) {
|
||||||
setGridWidth(gridContainerRef.current.offsetWidth)
|
const rect = gridContainerRef.current.getBoundingClientRect()
|
||||||
|
setGridWidth(rect.width)
|
||||||
|
} else if (typeof window !== 'undefined') {
|
||||||
|
setGridWidth(window.innerWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,16 +60,44 @@ export default function Dashboard() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const layout = config.widgets.map((widget) => ({
|
const layout = config.widgets.map((widget) => {
|
||||||
|
const position = widget.position ?? { x: 0, y: Infinity, w: 1, h: 1 }
|
||||||
|
return {
|
||||||
i: widget.id,
|
i: widget.id,
|
||||||
x: widget.position?.x || 0,
|
x: position.x ?? 0,
|
||||||
y: widget.position?.y || 0,
|
y: position.y ?? Infinity,
|
||||||
w: widget.position?.w || 1,
|
w: Math.max(position.w ?? 1, 1),
|
||||||
h: widget.position?.h || 2,
|
h: Math.max(position.h ?? 1, 1),
|
||||||
minW: 1,
|
minW: 1,
|
||||||
minH: 1,
|
minH: 1,
|
||||||
maxW: 4,
|
maxW: GRID_COLUMNS,
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleWidgetHeightChange = useCallback(
|
||||||
|
(widgetId: string, contentHeight: number) => {
|
||||||
|
const widget = config.widgets.find((w) => w.id === widgetId)
|
||||||
|
if (!widget) return
|
||||||
|
|
||||||
|
const position = widget.position ?? { x: 0, y: Infinity, w: 1, h: 1 }
|
||||||
|
const currentRows = Math.max(position.h ?? 1, 1)
|
||||||
|
const desiredPixelHeight = contentHeight + HEIGHT_PADDING
|
||||||
|
const targetRows = Math.min(
|
||||||
|
MAX_AUTO_ROWS,
|
||||||
|
Math.max(1, Math.ceil(desiredPixelHeight / ROW_UNIT))
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Math.abs(targetRows - currentRows) >= 1) {
|
||||||
|
updateWidget(widgetId, {
|
||||||
|
position: {
|
||||||
|
...position,
|
||||||
|
h: targetRows,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config.widgets, updateWidget]
|
||||||
|
)
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
const json = exportConfig()
|
const json = exportConfig()
|
||||||
@@ -83,6 +128,21 @@ export default function Dashboard() {
|
|||||||
reader.readAsText(file)
|
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 (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<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
|
Customize your view with modular widgets
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
className="btn btn-outline btn-sm"
|
className="btn btn-outline btn-sm"
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
@@ -152,8 +235,8 @@ export default function Dashboard() {
|
|||||||
<GridLayout
|
<GridLayout
|
||||||
className="layout"
|
className="layout"
|
||||||
layout={layout}
|
layout={layout}
|
||||||
cols={4}
|
cols={GRID_COLUMNS}
|
||||||
rowHeight={150}
|
rowHeight={ROW_HEIGHT}
|
||||||
width={gridWidth}
|
width={gridWidth}
|
||||||
onLayoutChange={handleLayoutChange}
|
onLayoutChange={handleLayoutChange}
|
||||||
draggableHandle=".drag-handle"
|
draggableHandle=".drag-handle"
|
||||||
@@ -161,7 +244,8 @@ export default function Dashboard() {
|
|||||||
preventCollision={false}
|
preventCollision={false}
|
||||||
isResizable={true}
|
isResizable={true}
|
||||||
isDraggable={true}
|
isDraggable={true}
|
||||||
margin={[12, 12]}
|
margin={GRID_MARGIN}
|
||||||
|
containerPadding={[0, 0]}
|
||||||
>
|
>
|
||||||
{config.widgets.map((widget) => (
|
{config.widgets.map((widget) => (
|
||||||
<div key={widget.id} className="h-full">
|
<div key={widget.id} className="h-full">
|
||||||
@@ -169,6 +253,7 @@ export default function Dashboard() {
|
|||||||
config={widget}
|
config={widget}
|
||||||
onRemove={() => removeWidget(widget.id)}
|
onRemove={() => removeWidget(widget.id)}
|
||||||
onEdit={() => setEditingWidget(widget.id)}
|
onEdit={() => setEditingWidget(widget.id)}
|
||||||
|
onHeightChange={(height: number) => handleWidgetHeightChange(widget.id, height)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
2
infrastructure/.gitignore
vendored
2
infrastructure/.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
mosquitto/certs/
|
mosquitto/certs/
|
||||||
mosquitto/data/
|
mosquitto/data/
|
||||||
mosquitto/logs/
|
mosquitto/logs/
|
||||||
|
.env
|
||||||
|
.env*
|
||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
mqtt:
|
mosquitto:
|
||||||
image: eclipse-mosquitto:2.0
|
image: eclipse-mosquitto:2.0
|
||||||
ports:
|
ports:
|
||||||
- "1883:1883"
|
- "1883:1883"
|
||||||
@@ -30,6 +30,39 @@ services:
|
|||||||
- timescaledb-data:/var/lib/postgresql/data
|
- timescaledb-data:/var/lib/postgresql/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db-write:
|
||||||
|
build:
|
||||||
|
context: ../services/db_write
|
||||||
|
env_file:
|
||||||
|
- .env.dbw
|
||||||
|
depends_on:
|
||||||
|
- timescaledb
|
||||||
|
- redis
|
||||||
|
- mqtt-ingestion
|
||||||
|
restart:
|
||||||
|
unless-stopped
|
||||||
|
|
||||||
|
mqtt-ingestion:
|
||||||
|
build:
|
||||||
|
context: ../services/mqtt_ingestion
|
||||||
|
env_file:
|
||||||
|
- .env.mqi
|
||||||
|
depends_on:
|
||||||
|
- mosquitto
|
||||||
|
- redis
|
||||||
|
restart:
|
||||||
|
unless-stopped
|
||||||
|
|
||||||
|
# gpt-service:
|
||||||
|
# build:
|
||||||
|
# context: ./services/gpt_service
|
||||||
|
# ports:
|
||||||
|
# - "8001:8001"
|
||||||
|
# restart: unless-stopped
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
redis-data:
|
redis-data:
|
||||||
timescaledb-data:
|
timescaledb-data:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from iotDashboard.models import Device, DeviceCertificate, Telemetry
|
from iotDashboard.models import Device, DeviceCertificate, Telemetry
|
||||||
|
from iotDashboard.dashboard_models import DashboardLayout
|
||||||
|
|
||||||
|
|
||||||
class DeviceCertificateSerializer(serializers.ModelSerializer):
|
class DeviceCertificateSerializer(serializers.ModelSerializer):
|
||||||
@@ -75,3 +76,13 @@ class DashboardOverviewSerializer(serializers.Serializer):
|
|||||||
certificates_expiring_soon = serializers.IntegerField()
|
certificates_expiring_soon = serializers.IntegerField()
|
||||||
recent_telemetry = TelemetrySerializer(many=True)
|
recent_telemetry = TelemetrySerializer(many=True)
|
||||||
devices_with_metrics = DeviceMetricsSerializer(many=True)
|
devices_with_metrics = DeviceMetricsSerializer(many=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Dashboard Layout Serializers
|
||||||
|
class DashboardLayoutSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for dashboard layouts (single-user system)."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DashboardLayout
|
||||||
|
fields = ['id', 'name', 'config', 'is_default', 'created_at', 'updated_at']
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|||||||
@@ -2,13 +2,19 @@
|
|||||||
|
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
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 = DefaultRouter()
|
||||||
router.register(r'devices', DeviceViewSet, basename='device')
|
router.register(r'devices', DeviceViewSet, basename='device')
|
||||||
router.register(r'telemetry', TelemetryViewSet, basename='telemetry')
|
router.register(r'telemetry', TelemetryViewSet, basename='telemetry')
|
||||||
router.register(r'dashboard', DashboardViewSet, basename='dashboard')
|
router.register(r'dashboard', DashboardViewSet, basename='dashboard')
|
||||||
router.register(r'weather', WeatherViewSet, basename='weather')
|
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 = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
"""DRF ViewSets for IoT Dashboard API."""
|
"""DRF ViewSets for IoT Dashboard API."""
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from datetime import timedelta
|
from datetime import timedelta, datetime
|
||||||
|
from urllib.parse import urlparse
|
||||||
from django.utils import timezone
|
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.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
from iotDashboard.models import Device, DeviceCertificate, Telemetry
|
from iotDashboard.models import Device, DeviceCertificate, Telemetry
|
||||||
|
from iotDashboard.dashboard_models import DashboardLayout
|
||||||
from iotDashboard.device_manager_client import (
|
from iotDashboard.device_manager_client import (
|
||||||
DeviceManagerClient,
|
DeviceManagerClient,
|
||||||
DeviceManagerAPIError
|
DeviceManagerAPIError
|
||||||
@@ -20,11 +22,23 @@ from iotDashboard.comfort_index import (
|
|||||||
ComfortIndexCalculator,
|
ComfortIndexCalculator,
|
||||||
calculate_comfort_index_from_telemetry,
|
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 (
|
from .serializers import (
|
||||||
DeviceSerializer,
|
DeviceSerializer,
|
||||||
DeviceCreateSerializer,
|
DeviceCreateSerializer,
|
||||||
TelemetrySerializer,
|
TelemetrySerializer,
|
||||||
DashboardOverviewSerializer,
|
DashboardOverviewSerializer,
|
||||||
|
DashboardLayoutSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -352,7 +366,7 @@ class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
)
|
)
|
||||||
return Response({'metrics': list(metrics)})
|
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):
|
def analyze(self, request):
|
||||||
"""Analyze telemetry data using GPT service."""
|
"""Analyze telemetry data using GPT service."""
|
||||||
# Parse request parameters
|
# Parse request parameters
|
||||||
@@ -422,9 +436,9 @@ class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
custom_prompt=custom_prompt
|
custom_prompt=custom_prompt
|
||||||
)
|
)
|
||||||
return Response({
|
return Response({
|
||||||
'analysis': result.analysis,
|
'analysis': result['analysis'],
|
||||||
'prompt_type': result.prompt_type,
|
'prompt_type': result['prompt_type'],
|
||||||
'data_points_analyzed': result.data_points_analyzed
|
'data_points_analyzed': result['data_points_analyzed']
|
||||||
})
|
})
|
||||||
|
|
||||||
except gpt_service_client.GPTServiceError as e:
|
except gpt_service_client.GPTServiceError as e:
|
||||||
@@ -576,3 +590,552 @@ class WeatherViewSet(viewsets.ViewSet):
|
|||||||
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarViewSet(viewsets.ViewSet):
|
||||||
|
""" ViewSet for Calendar"""
|
||||||
|
|
||||||
|
@action(detail=False,methods=['get'])
|
||||||
|
def fetch(self,request):
|
||||||
|
""" Fetch calendar events """
|
||||||
|
calendar_url = request.query_params.get('calendar_url')
|
||||||
|
if not calendar_url:
|
||||||
|
return Response(
|
||||||
|
{'error': 'calendar_url parameter is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = urlparse(calendar_url)
|
||||||
|
if parsed.scheme not in ('http', 'https'):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only http/https calendar URLs are supported'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Keep timeout small to avoid hanging the API worker
|
||||||
|
calendar_response = requests.get(calendar_url, timeout=10)
|
||||||
|
calendar_response.raise_for_status()
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'error': 'Failed to fetch calendar feed',
|
||||||
|
'details': str(exc),
|
||||||
|
},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = calendar_response.text or ''
|
||||||
|
if not text.strip():
|
||||||
|
return Response(
|
||||||
|
{'error': 'Calendar feed returned no data'},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'calendar_data': text})
|
||||||
|
|
||||||
|
|
||||||
|
class WellnessViewSet(viewsets.ViewSet):
|
||||||
|
"""ViewSet for wellness analysis combining health and environmental data."""
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def run_suitability(self, request):
|
||||||
|
"""
|
||||||
|
Calculate run suitability combining weather, air quality, and health data.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- health_device_id: Device ID for health metrics (required)
|
||||||
|
- city: City name for weather/air quality (required)
|
||||||
|
- time_of_day: Optional time override (ISO format)
|
||||||
|
"""
|
||||||
|
health_device_id = request.query_params.get('health_device_id')
|
||||||
|
city = request.query_params.get('city')
|
||||||
|
time_of_day_str = request.query_params.get('time_of_day')
|
||||||
|
|
||||||
|
if not health_device_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'health_device_id parameter is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if not city:
|
||||||
|
return Response(
|
||||||
|
{'error': 'city parameter is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get health device
|
||||||
|
try:
|
||||||
|
health_device = Device.objects.get(id=health_device_id)
|
||||||
|
except Device.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': f'Health device {health_device_id} not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch weather data
|
||||||
|
try:
|
||||||
|
weather_data = weather_client.get_weather_by_city(city)
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{'error': f'Failed to fetch weather data: {str(e)}'},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch air quality data
|
||||||
|
try:
|
||||||
|
raw_air_quality = weather_client.get_air_quality(city.lower())
|
||||||
|
air_quality_data = weather_client.parse_air_quality_data(raw_air_quality, city.lower())
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{'error': f'Failed to fetch air quality data: {str(e)}'},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get health metrics from telemetry
|
||||||
|
health_metrics = {}
|
||||||
|
health_metric_names = {
|
||||||
|
'steps': ['steps', 'step_count'],
|
||||||
|
'active_calories': ['active_calories', 'calories'],
|
||||||
|
'heart_rate': ['heart_rate', 'heart_rate_bpm', 'hr'],
|
||||||
|
'resting_heart_rate': ['resting_heart_rate', 'resting_hr'],
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, possible_names in health_metric_names.items():
|
||||||
|
for metric_name in possible_names:
|
||||||
|
latest = (
|
||||||
|
Telemetry.objects
|
||||||
|
.filter(device_id=health_device_id, metric=metric_name)
|
||||||
|
.order_by('-time')
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if latest:
|
||||||
|
health_metrics[key] = float(latest.value)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Get current time
|
||||||
|
if time_of_day_str:
|
||||||
|
try:
|
||||||
|
current_time = datetime.fromisoformat(time_of_day_str.replace('Z', '+00:00'))
|
||||||
|
except ValueError:
|
||||||
|
current_time = timezone.now()
|
||||||
|
else:
|
||||||
|
current_time = timezone.now()
|
||||||
|
|
||||||
|
# Prepare data for calculator
|
||||||
|
weather = WeatherData(
|
||||||
|
temperature=weather_data.get('temperature', 20),
|
||||||
|
apparent_temperature=weather_data.get('apparent_temperature', 20),
|
||||||
|
wind_speed=weather_data.get('wind_speed', 0),
|
||||||
|
precipitation=weather_data.get('precipitation', 0),
|
||||||
|
rain=weather_data.get('rain', 0),
|
||||||
|
weather_code=weather_data.get('weather_code', 0),
|
||||||
|
humidity=weather_data.get('humidity', 50),
|
||||||
|
cloud_cover=weather_data.get('cloud_cover', 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
air_quality = AirQualityData(
|
||||||
|
pm25=air_quality_data.get('measurements', {}).get('pm25', {}).get('average'),
|
||||||
|
pm10=air_quality_data.get('measurements', {}).get('pm10', {}).get('average'),
|
||||||
|
status=air_quality_data.get('status', 'Unknown'),
|
||||||
|
)
|
||||||
|
|
||||||
|
health = HealthData(
|
||||||
|
steps_today=health_metrics.get('steps', 0),
|
||||||
|
active_calories=health_metrics.get('active_calories', 0),
|
||||||
|
heart_rate=health_metrics.get('heart_rate'),
|
||||||
|
resting_heart_rate=health_metrics.get('resting_heart_rate'),
|
||||||
|
daily_goal_steps=10000, # Default goal
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate run suitability
|
||||||
|
result = RunSuitabilityCalculator.calculate(
|
||||||
|
weather=weather,
|
||||||
|
air_quality=air_quality,
|
||||||
|
health=health,
|
||||||
|
current_time=current_time
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'status': result.status,
|
||||||
|
'overall_score': result.overall_score,
|
||||||
|
'scores': {
|
||||||
|
'weather': result.weather_score,
|
||||||
|
'air_quality': result.air_quality_score,
|
||||||
|
'health': result.health_score,
|
||||||
|
},
|
||||||
|
'primary_reason': result.primary_reason,
|
||||||
|
'detailed_insights': result.detailed_insights,
|
||||||
|
'time_recommendations': result.time_recommendations,
|
||||||
|
'suggestions': result.suggestions,
|
||||||
|
'weather_data': {
|
||||||
|
'temperature': weather.temperature,
|
||||||
|
'wind_speed': weather.wind_speed,
|
||||||
|
'precipitation': weather.precipitation,
|
||||||
|
'description': weather_data.get('weather_description'),
|
||||||
|
},
|
||||||
|
'air_quality_data': {
|
||||||
|
'pm25': air_quality.pm25,
|
||||||
|
'pm10': air_quality.pm10,
|
||||||
|
'status': air_quality.status,
|
||||||
|
},
|
||||||
|
'health_data': {
|
||||||
|
'steps': health.steps_today,
|
||||||
|
'active_calories': health.active_calories,
|
||||||
|
'heart_rate': health.heart_rate,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{'error': f'Failed to calculate run suitability: {str(e)}'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def health_insights(self, request):
|
||||||
|
"""
|
||||||
|
Get contextual health insights with environmental correlations.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- health_device_id: Device ID for health metrics (required)
|
||||||
|
- city: City name for weather/air quality context (optional)
|
||||||
|
"""
|
||||||
|
health_device_id = request.query_params.get('health_device_id')
|
||||||
|
city = request.query_params.get('city')
|
||||||
|
|
||||||
|
if not health_device_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'health_device_id parameter is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get health device
|
||||||
|
try:
|
||||||
|
health_device = Device.objects.get(id=health_device_id)
|
||||||
|
except Device.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': f'Health device {health_device_id} not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get health metrics from telemetry
|
||||||
|
health_metrics = {}
|
||||||
|
health_metric_names = {
|
||||||
|
'steps': ['steps', 'step_count'],
|
||||||
|
'active_calories': ['active_calories', 'calories'],
|
||||||
|
'heart_rate': ['heart_rate', 'heart_rate_bpm', 'hr'],
|
||||||
|
'resting_heart_rate': ['resting_heart_rate', 'resting_hr'],
|
||||||
|
'sleep_duration': ['sleep_duration', 'sleep'],
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, possible_names in health_metric_names.items():
|
||||||
|
for metric_name in possible_names:
|
||||||
|
latest = (
|
||||||
|
Telemetry.objects
|
||||||
|
.filter(device_id=health_device_id, metric=metric_name)
|
||||||
|
.order_by('-time')
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if latest:
|
||||||
|
health_metrics[key] = float(latest.value)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Get environmental context (optional)
|
||||||
|
env_context = EnvironmentalContext()
|
||||||
|
if city:
|
||||||
|
try:
|
||||||
|
weather_data = weather_client.get_weather_by_city(city)
|
||||||
|
env_context.temperature = weather_data.get('temperature')
|
||||||
|
env_context.humidity = weather_data.get('humidity')
|
||||||
|
env_context.weather_description = weather_data.get('weather_description')
|
||||||
|
except Exception:
|
||||||
|
pass # Weather optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_air_quality = weather_client.get_air_quality(city.lower())
|
||||||
|
air_quality_data = weather_client.parse_air_quality_data(raw_air_quality, city.lower())
|
||||||
|
env_context.pm25 = air_quality_data.get('measurements', {}).get('pm25', {}).get('average')
|
||||||
|
env_context.pm10 = air_quality_data.get('measurements', {}).get('pm10', {}).get('average')
|
||||||
|
env_context.air_quality_status = air_quality_data.get('status')
|
||||||
|
except Exception:
|
||||||
|
pass # Air quality optional
|
||||||
|
|
||||||
|
# Prepare health metrics
|
||||||
|
health = HealthMetrics(
|
||||||
|
steps=int(health_metrics.get('steps', 0)),
|
||||||
|
active_calories=int(health_metrics.get('active_calories', 0)),
|
||||||
|
heart_rate=health_metrics.get('heart_rate'),
|
||||||
|
resting_heart_rate=health_metrics.get('resting_heart_rate'),
|
||||||
|
sleep_duration=health_metrics.get('sleep_duration'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate insights
|
||||||
|
result = HealthInsightsCalculator.calculate(health, env_context)
|
||||||
|
|
||||||
|
# Format insights for response
|
||||||
|
insights_list = [
|
||||||
|
{
|
||||||
|
'metric': insight.metric,
|
||||||
|
'value': insight.value,
|
||||||
|
'context': insight.context,
|
||||||
|
'correlation': insight.correlation,
|
||||||
|
'recommendation': insight.recommendation,
|
||||||
|
}
|
||||||
|
for insight in result.insights
|
||||||
|
]
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'health_metrics': result.health_metrics,
|
||||||
|
'environmental_context': result.environmental_context,
|
||||||
|
'insights': insights_list,
|
||||||
|
'correlations': result.correlations,
|
||||||
|
'recommendations': result.recommendations,
|
||||||
|
'trend_indicators': result.trend_indicators,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{'error': f'Failed to calculate health insights: {str(e)}'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def daily_briefing(self, request):
|
||||||
|
"""
|
||||||
|
Generate a daily briefing combining environment, schedule, and health data.
|
||||||
|
|
||||||
|
POST body:
|
||||||
|
- briefing_type: 'schedule', 'environment', or 'full' (required)
|
||||||
|
- city: City name for weather/air quality (required)
|
||||||
|
- health_device_id: Device ID for health metrics (optional)
|
||||||
|
- calendar_url: iCal URL for calendar events (optional)
|
||||||
|
- calendar_range_hours: Hours to look ahead for events (default 24)
|
||||||
|
"""
|
||||||
|
briefing_type = request.data.get('briefing_type', 'full')
|
||||||
|
city = request.data.get('city')
|
||||||
|
health_device_id = request.data.get('health_device_id')
|
||||||
|
calendar_url = request.data.get('calendar_url')
|
||||||
|
calendar_range_hours = int(request.data.get('calendar_range_hours', 24))
|
||||||
|
|
||||||
|
if briefing_type not in ('schedule', 'environment', 'full'):
|
||||||
|
return Response(
|
||||||
|
{'error': 'briefing_type must be schedule, environment, or full'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if not city:
|
||||||
|
return Response(
|
||||||
|
{'error': 'city parameter is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_time = timezone.now()
|
||||||
|
|
||||||
|
# Gather indoor data from all devices
|
||||||
|
indoor_data = {}
|
||||||
|
indoor_metrics = ['temperature', 'humidity', 'co2', 'CO2', 'noise', 'light', 'pm25', 'pm2.5']
|
||||||
|
|
||||||
|
for metric in indoor_metrics:
|
||||||
|
latest = (
|
||||||
|
Telemetry.objects
|
||||||
|
.filter(metric__iexact=metric)
|
||||||
|
.order_by('-time')
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if latest:
|
||||||
|
# Normalize metric names
|
||||||
|
normalized = metric.lower().replace('.', '')
|
||||||
|
if normalized == 'co2':
|
||||||
|
indoor_data['co2_ppm'] = float(latest.value)
|
||||||
|
elif normalized == 'pm25':
|
||||||
|
indoor_data['indoor_pm25'] = float(latest.value)
|
||||||
|
else:
|
||||||
|
indoor_data[f'{normalized}'] = float(latest.value)
|
||||||
|
|
||||||
|
# Gather outdoor data (weather + air quality)
|
||||||
|
outdoor_data = {}
|
||||||
|
try:
|
||||||
|
weather = weather_client.get_weather_by_city(city)
|
||||||
|
outdoor_data['temperature'] = weather.get('temperature')
|
||||||
|
outdoor_data['apparent_temperature'] = weather.get('apparent_temperature')
|
||||||
|
outdoor_data['humidity'] = weather.get('humidity')
|
||||||
|
outdoor_data['weather'] = weather.get('weather_description')
|
||||||
|
outdoor_data['wind_speed'] = weather.get('wind_speed')
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Failed to fetch weather: {e}") if hasattr(self, 'logger') else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_aq = weather_client.get_air_quality(city.lower())
|
||||||
|
aq = weather_client.parse_air_quality_data(raw_aq, city.lower())
|
||||||
|
outdoor_data['pm25'] = aq.get('measurements', {}).get('pm25', {}).get('average')
|
||||||
|
outdoor_data['pm10'] = aq.get('measurements', {}).get('pm10', {}).get('average')
|
||||||
|
outdoor_data['air_quality_status'] = aq.get('status')
|
||||||
|
except Exception as e:
|
||||||
|
pass # Air quality optional
|
||||||
|
|
||||||
|
# Gather health data if device specified
|
||||||
|
health_data = None
|
||||||
|
if health_device_id:
|
||||||
|
health_data = {}
|
||||||
|
health_metric_names = {
|
||||||
|
'steps': ['steps', 'step_count'],
|
||||||
|
'active_calories': ['active_calories', 'calories'],
|
||||||
|
'heart_rate': ['heart_rate', 'heart_rate_bpm', 'hr'],
|
||||||
|
'resting_heart_rate': ['resting_heart_rate', 'resting_hr'],
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, possible_names in health_metric_names.items():
|
||||||
|
for metric_name in possible_names:
|
||||||
|
latest = (
|
||||||
|
Telemetry.objects
|
||||||
|
.filter(device_id=health_device_id, metric=metric_name)
|
||||||
|
.order_by('-time')
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if latest:
|
||||||
|
health_data[key] = float(latest.value)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Parse calendar events if URL provided
|
||||||
|
calendar_events = None
|
||||||
|
if calendar_url:
|
||||||
|
try:
|
||||||
|
cal_response = requests.get(calendar_url, timeout=10)
|
||||||
|
cal_response.raise_for_status()
|
||||||
|
cal_text = cal_response.text
|
||||||
|
|
||||||
|
# Parse iCal using icalendar library or simple parsing
|
||||||
|
calendar_events = self._parse_ical_events(
|
||||||
|
cal_text,
|
||||||
|
current_time,
|
||||||
|
calendar_range_hours
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
pass # Calendar optional
|
||||||
|
|
||||||
|
# Call GPT service
|
||||||
|
result = async_to_sync(gpt_service_client.get_gpt_client().generate_daily_briefing)(
|
||||||
|
briefing_type=briefing_type,
|
||||||
|
current_time=current_time.isoformat(),
|
||||||
|
indoor_data=indoor_data if indoor_data else None,
|
||||||
|
outdoor_data=outdoor_data if outdoor_data else None,
|
||||||
|
health_data=health_data,
|
||||||
|
calendar_events=calendar_events,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add context data to response
|
||||||
|
result['context'] = {
|
||||||
|
'indoor': indoor_data,
|
||||||
|
'outdoor': outdoor_data,
|
||||||
|
'health': health_data,
|
||||||
|
'calendar_event_count': len(calendar_events) if calendar_events else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(result)
|
||||||
|
|
||||||
|
except gpt_service_client.GPTServiceError as e:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'error': e.message,
|
||||||
|
'details': e.details,
|
||||||
|
'gpt_service_available': False
|
||||||
|
},
|
||||||
|
status=e.status_code or status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{'error': f'Failed to generate daily briefing: {str(e)}'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_ical_events(self, ical_text: str, start_time, range_hours: int):
|
||||||
|
"""Parse iCal text and extract events within the time range."""
|
||||||
|
from datetime import timedelta
|
||||||
|
import re
|
||||||
|
|
||||||
|
events = []
|
||||||
|
end_time = start_time + timedelta(hours=range_hours)
|
||||||
|
|
||||||
|
# Simple iCal parsing (handles basic VEVENT blocks)
|
||||||
|
vevent_pattern = re.compile(r'BEGIN:VEVENT.*?END:VEVENT', re.DOTALL)
|
||||||
|
|
||||||
|
for match in vevent_pattern.finditer(ical_text):
|
||||||
|
event_text = match.group()
|
||||||
|
event = {}
|
||||||
|
|
||||||
|
# Extract summary
|
||||||
|
summary_match = re.search(r'SUMMARY[^:]*:(.+?)(?:\r?\n|$)', event_text)
|
||||||
|
if summary_match:
|
||||||
|
event['summary'] = summary_match.group(1).strip()
|
||||||
|
else:
|
||||||
|
event['summary'] = 'Untitled'
|
||||||
|
|
||||||
|
# Extract start time
|
||||||
|
dtstart_match = re.search(r'DTSTART[^:]*:(\d{8}T?\d{0,6}Z?)', event_text)
|
||||||
|
if dtstart_match:
|
||||||
|
dt_str = dtstart_match.group(1)
|
||||||
|
try:
|
||||||
|
if 'T' in dt_str:
|
||||||
|
# DateTime format
|
||||||
|
if dt_str.endswith('Z'):
|
||||||
|
dt = datetime.strptime(dt_str, '%Y%m%dT%H%M%SZ')
|
||||||
|
else:
|
||||||
|
dt = datetime.strptime(dt_str[:15], '%Y%m%dT%H%M%S')
|
||||||
|
event['start'] = dt.strftime('%I:%M %p')
|
||||||
|
else:
|
||||||
|
# Date only (all-day event)
|
||||||
|
dt = datetime.strptime(dt_str, '%Y%m%d')
|
||||||
|
event['start'] = 'All day'
|
||||||
|
|
||||||
|
# Check if event is within range
|
||||||
|
if dt.replace(tzinfo=None) < start_time.replace(tzinfo=None):
|
||||||
|
continue
|
||||||
|
if dt.replace(tzinfo=None) > end_time.replace(tzinfo=None):
|
||||||
|
continue
|
||||||
|
except:
|
||||||
|
event['start'] = 'TBD'
|
||||||
|
|
||||||
|
# Extract location
|
||||||
|
location_match = re.search(r'LOCATION[^:]*:(.+?)(?:\r?\n|$)', event_text)
|
||||||
|
if location_match:
|
||||||
|
event['location'] = location_match.group(1).strip()
|
||||||
|
|
||||||
|
if event.get('summary'):
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
# Sort by start time and limit
|
||||||
|
return events[:15]
|
||||||
|
|
||||||
|
|
||||||
|
# Dashboard Layout Views
|
||||||
|
class DashboardLayoutViewSet(viewsets.ModelViewSet):
|
||||||
|
"""ViewSet for managing dashboard layouts (single-user system)."""
|
||||||
|
|
||||||
|
serializer_class = DashboardLayoutSerializer
|
||||||
|
permission_classes = [permissions.AllowAny] # No auth required for single-user system
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Return all layouts (single-user system)."""
|
||||||
|
return DashboardLayout.objects.all()
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def default(self, request):
|
||||||
|
"""Get the default layout."""
|
||||||
|
layout = DashboardLayout.get_default()
|
||||||
|
return Response(self.get_serializer(layout).data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def set_default(self, request, pk=None):
|
||||||
|
"""Set a layout as the default."""
|
||||||
|
layout = self.get_object()
|
||||||
|
# Unset other defaults
|
||||||
|
DashboardLayout.objects.filter(is_default=True).update(is_default=False)
|
||||||
|
# Set this one as default
|
||||||
|
layout.is_default = True
|
||||||
|
layout.save()
|
||||||
|
return Response(self.get_serializer(layout).data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
65
iotDashboard/dashboard_models.py
Normal file
65
iotDashboard/dashboard_models.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
Dashboard layout models for persistent storage in PostgreSQL.
|
||||||
|
|
||||||
|
Single-user system: Stores dashboard configuration for the default user.
|
||||||
|
PostgreSQL is chosen over Redis because:
|
||||||
|
- Dashboard layouts are persistent configuration data (not cache)
|
||||||
|
- Need ACID guarantees for data integrity
|
||||||
|
- Low read/write frequency (performance difference negligible)
|
||||||
|
- Already using PostgreSQL for Django
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardLayout(models.Model):
|
||||||
|
"""Stores dashboard configuration in PostgreSQL.
|
||||||
|
|
||||||
|
Single-user system: Only one default layout is stored.
|
||||||
|
This is persistent configuration data that should survive server restarts.
|
||||||
|
PostgreSQL provides:
|
||||||
|
- ACID guarantees
|
||||||
|
- Complex queries
|
||||||
|
- Backup/restore capabilities
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
default='default',
|
||||||
|
unique=True,
|
||||||
|
help_text="Layout name (e.g., 'default', 'mobile', 'work')"
|
||||||
|
)
|
||||||
|
config = models.JSONField(
|
||||||
|
help_text="Full dashboard configuration JSON"
|
||||||
|
)
|
||||||
|
is_default = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether this is the default layout"
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "dashboard_layouts"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["is_default"]),
|
||||||
|
models.Index(fields=["name"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} (default: {self.is_default})"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_default(cls):
|
||||||
|
"""Get the default layout, or create one if it doesn't exist."""
|
||||||
|
layout = cls.objects.filter(is_default=True).first()
|
||||||
|
if layout:
|
||||||
|
return layout
|
||||||
|
# Create default if none exists
|
||||||
|
return cls.objects.create(
|
||||||
|
name='default',
|
||||||
|
config={'widgets': [], 'layout': 'grid', 'refreshInterval': 30000},
|
||||||
|
is_default=True
|
||||||
|
)
|
||||||
|
|
||||||
@@ -56,6 +56,75 @@ class GPTServiceClient:
|
|||||||
logger.error(f"GPT service health check failed: {e}")
|
logger.error(f"GPT service health check failed: {e}")
|
||||||
raise
|
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(
|
async def analyze_telemetry(
|
||||||
self,
|
self,
|
||||||
telemetry_data: List[Dict[str, Any]],
|
telemetry_data: List[Dict[str, Any]],
|
||||||
|
|||||||
354
iotDashboard/health_insights.py
Normal file
354
iotDashboard/health_insights.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
"""
|
||||||
|
Health Insights Calculator
|
||||||
|
|
||||||
|
Correlates health metrics with environmental data to provide contextual insights.
|
||||||
|
Shows how weather and air quality affect health metrics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthMetrics:
|
||||||
|
"""Current health metrics."""
|
||||||
|
steps: int = 0
|
||||||
|
active_calories: int = 0
|
||||||
|
heart_rate: Optional[float] = None
|
||||||
|
resting_heart_rate: Optional[float] = None
|
||||||
|
sleep_duration: Optional[float] = None # minutes
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EnvironmentalContext:
|
||||||
|
"""Environmental context for health insights."""
|
||||||
|
temperature: Optional[float] = None
|
||||||
|
humidity: Optional[float] = None
|
||||||
|
pm25: Optional[float] = None
|
||||||
|
pm10: Optional[float] = None
|
||||||
|
air_quality_status: Optional[str] = None
|
||||||
|
weather_description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthInsight:
|
||||||
|
"""Individual health insight."""
|
||||||
|
metric: str
|
||||||
|
value: float
|
||||||
|
context: str
|
||||||
|
correlation: Optional[str] = None
|
||||||
|
recommendation: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthInsightsResult:
|
||||||
|
"""Health insights with environmental context."""
|
||||||
|
health_metrics: Dict[str, float]
|
||||||
|
environmental_context: Dict[str, Optional[float]]
|
||||||
|
insights: List[HealthInsight]
|
||||||
|
correlations: List[str]
|
||||||
|
recommendations: List[str]
|
||||||
|
trend_indicators: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class HealthInsightsCalculator:
|
||||||
|
"""Calculate contextual health insights from health and environmental data."""
|
||||||
|
|
||||||
|
# Thresholds for correlations
|
||||||
|
TEMP_HR_CORRELATION_THRESHOLD = 3.0 # bpm per degree C above 22°C
|
||||||
|
PM25_HR_THRESHOLD = 20.0 # μg/m³ - above this may affect HR
|
||||||
|
PM10_HR_THRESHOLD = 30.0 # μg/m³ - above this may affect HR
|
||||||
|
OPTIMAL_TEMP_FOR_ACTIVITY = (18.0, 22.0) # °C
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def analyze_heart_rate(
|
||||||
|
hr: Optional[float],
|
||||||
|
resting_hr: Optional[float],
|
||||||
|
env: EnvironmentalContext
|
||||||
|
) -> List[HealthInsight]:
|
||||||
|
"""Analyze heart rate with environmental context."""
|
||||||
|
insights = []
|
||||||
|
|
||||||
|
if hr is None:
|
||||||
|
return insights
|
||||||
|
|
||||||
|
# Base insight
|
||||||
|
hr_insight = HealthInsight(
|
||||||
|
metric="Heart Rate",
|
||||||
|
value=hr,
|
||||||
|
context=f"Current: {hr:.0f} bpm"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compare with resting HR
|
||||||
|
if resting_hr is not None:
|
||||||
|
elevation = hr - resting_hr
|
||||||
|
if elevation > 20:
|
||||||
|
hr_insight.context += f" (elevated by {elevation:.0f} bpm from resting)"
|
||||||
|
if env.temperature is not None and env.temperature > 25:
|
||||||
|
hr_insight.correlation = f"High temperature ({env.temperature:.1f}°C) may be contributing to elevated HR"
|
||||||
|
hr_insight.recommendation = "Stay hydrated and avoid intense activity in heat"
|
||||||
|
elif elevation > 10:
|
||||||
|
hr_insight.context += f" (slightly elevated by {elevation:.0f} bpm)"
|
||||||
|
|
||||||
|
# Temperature correlation
|
||||||
|
if env.temperature is not None:
|
||||||
|
if env.temperature > 25:
|
||||||
|
expected_hr_increase = (env.temperature - 22) * HealthInsightsCalculator.TEMP_HR_CORRELATION_THRESHOLD
|
||||||
|
if hr_insight.correlation is None:
|
||||||
|
hr_insight.correlation = f"Temperature ({env.temperature:.1f}°C) may increase HR by ~{expected_hr_increase:.0f} bpm"
|
||||||
|
elif env.temperature < 15:
|
||||||
|
hr_insight.correlation = f"Cool temperature ({env.temperature:.1f}°C) - HR may be lower than usual"
|
||||||
|
|
||||||
|
# Air quality correlation
|
||||||
|
if env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD:
|
||||||
|
if hr_insight.correlation:
|
||||||
|
hr_insight.correlation += f". Poor air quality (PM2.5: {env.pm25:.1f} μg/m³) may also affect HR"
|
||||||
|
else:
|
||||||
|
hr_insight.correlation = f"Poor air quality (PM2.5: {env.pm25:.1f} μg/m³) may be affecting HR"
|
||||||
|
hr_insight.recommendation = "Consider indoor activity when air quality improves"
|
||||||
|
|
||||||
|
if env.pm10 is not None and env.pm10 > HealthInsightsCalculator.PM10_HR_THRESHOLD:
|
||||||
|
if hr_insight.correlation and "air quality" not in hr_insight.correlation.lower():
|
||||||
|
hr_insight.correlation += f". High PM10 ({env.pm10:.1f} μg/m³) may affect breathing"
|
||||||
|
|
||||||
|
insights.append(hr_insight)
|
||||||
|
return insights
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def analyze_activity(
|
||||||
|
steps: int,
|
||||||
|
calories: int,
|
||||||
|
env: EnvironmentalContext
|
||||||
|
) -> List[HealthInsight]:
|
||||||
|
"""Analyze activity levels with environmental context."""
|
||||||
|
insights = []
|
||||||
|
|
||||||
|
# Steps insight
|
||||||
|
steps_insight = HealthInsight(
|
||||||
|
metric="Steps",
|
||||||
|
value=steps,
|
||||||
|
context=f"Today: {steps:,} steps"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Activity level assessment
|
||||||
|
if steps >= 10000:
|
||||||
|
steps_insight.context += " - Excellent activity level!"
|
||||||
|
elif steps >= 7500:
|
||||||
|
steps_insight.context += " - Good activity level"
|
||||||
|
elif steps >= 5000:
|
||||||
|
steps_insight.context += " - Moderate activity"
|
||||||
|
else:
|
||||||
|
steps_insight.context += " - Low activity today"
|
||||||
|
if env.temperature is not None:
|
||||||
|
temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY
|
||||||
|
if temp_min <= env.temperature <= temp_max:
|
||||||
|
steps_insight.recommendation = f"Perfect weather ({env.temperature:.1f}°C) - great time for outdoor activity!"
|
||||||
|
elif env.temperature > temp_max:
|
||||||
|
steps_insight.recommendation = f"Warm weather ({env.temperature:.1f}°C) - consider early morning or evening activity"
|
||||||
|
else:
|
||||||
|
steps_insight.recommendation = f"Cool weather ({env.temperature:.1f}°C) - dress warmly for outdoor activity"
|
||||||
|
|
||||||
|
# Weather correlation
|
||||||
|
if env.weather_description:
|
||||||
|
if "clear" in env.weather_description.lower() or "sunny" in env.weather_description.lower():
|
||||||
|
if steps < 5000:
|
||||||
|
steps_insight.correlation = "Clear weather - perfect for outdoor activity"
|
||||||
|
elif "rain" in env.weather_description.lower() or "snow" in env.weather_description.lower():
|
||||||
|
steps_insight.correlation = f"Weather: {env.weather_description} - may limit outdoor activity"
|
||||||
|
|
||||||
|
insights.append(steps_insight)
|
||||||
|
|
||||||
|
# Calories insight
|
||||||
|
calories_insight = HealthInsight(
|
||||||
|
metric="Active Calories",
|
||||||
|
value=calories,
|
||||||
|
context=f"Today: {calories:,} kcal"
|
||||||
|
)
|
||||||
|
|
||||||
|
if calories >= 500:
|
||||||
|
calories_insight.context += " - Great calorie burn!"
|
||||||
|
elif calories >= 300:
|
||||||
|
calories_insight.context += " - Good calorie burn"
|
||||||
|
|
||||||
|
insights.append(calories_insight)
|
||||||
|
return insights
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_correlations(
|
||||||
|
health: HealthMetrics,
|
||||||
|
env: EnvironmentalContext
|
||||||
|
) -> List[str]:
|
||||||
|
"""Generate correlation statements."""
|
||||||
|
correlations = []
|
||||||
|
|
||||||
|
# HR vs Temperature
|
||||||
|
if health.heart_rate is not None and env.temperature is not None:
|
||||||
|
if env.temperature > 25:
|
||||||
|
correlations.append(f"Your HR ({health.heart_rate:.0f} bpm) may be elevated due to high temperature ({env.temperature:.1f}°C)")
|
||||||
|
elif env.temperature < 15:
|
||||||
|
correlations.append(f"Cool temperature ({env.temperature:.1f}°C) may result in lower HR than usual")
|
||||||
|
|
||||||
|
# HR vs Air Quality
|
||||||
|
if health.heart_rate is not None:
|
||||||
|
if env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD:
|
||||||
|
correlations.append(f"Elevated HR may be related to poor air quality (PM2.5: {env.pm25:.1f} μg/m³)")
|
||||||
|
if env.pm10 is not None and env.pm10 > HealthInsightsCalculator.PM10_HR_THRESHOLD:
|
||||||
|
correlations.append(f"High PM10 ({env.pm10:.1f} μg/m³) may affect breathing and HR")
|
||||||
|
|
||||||
|
# Activity vs Weather
|
||||||
|
if health.steps > 0 and env.weather_description:
|
||||||
|
if "clear" in env.weather_description.lower() or "sunny" in env.weather_description.lower():
|
||||||
|
if health.steps >= 10000:
|
||||||
|
correlations.append("Great activity on a clear day!")
|
||||||
|
elif "rain" in env.weather_description.lower():
|
||||||
|
correlations.append("Rainy weather may have limited outdoor activity")
|
||||||
|
|
||||||
|
# Activity vs Temperature
|
||||||
|
if health.steps > 0 and env.temperature is not None:
|
||||||
|
temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY
|
||||||
|
if temp_min <= env.temperature <= temp_max:
|
||||||
|
if health.steps >= 10000:
|
||||||
|
correlations.append(f"Optimal temperature ({env.temperature:.1f}°C) for high activity")
|
||||||
|
elif env.temperature > temp_max:
|
||||||
|
correlations.append(f"Warm weather ({env.temperature:.1f}°C) may reduce activity levels")
|
||||||
|
|
||||||
|
return correlations
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_recommendations(
|
||||||
|
health: HealthMetrics,
|
||||||
|
env: EnvironmentalContext
|
||||||
|
) -> List[str]:
|
||||||
|
"""Generate personalized recommendations."""
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
# Activity recommendations
|
||||||
|
if health.steps < 5000:
|
||||||
|
if env.temperature is not None:
|
||||||
|
temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY
|
||||||
|
if temp_min <= env.temperature <= temp_max:
|
||||||
|
recommendations.append(f"Perfect weather ({env.temperature:.1f}°C) - great time for a walk or run!")
|
||||||
|
elif env.temperature > temp_max:
|
||||||
|
recommendations.append(f"Warm weather ({env.temperature:.1f}°C) - consider early morning or evening activity")
|
||||||
|
else:
|
||||||
|
recommendations.append(f"Cool weather ({env.temperature:.1f}°C) - dress warmly for outdoor activity")
|
||||||
|
|
||||||
|
# Air quality recommendations
|
||||||
|
if env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD:
|
||||||
|
recommendations.append(f"Air quality is moderate (PM2.5: {env.pm25:.1f} μg/m³) - sensitive individuals should limit outdoor activity")
|
||||||
|
|
||||||
|
if env.pm10 is not None and env.pm10 > HealthInsightsCalculator.PM10_HR_THRESHOLD:
|
||||||
|
recommendations.append(f"High PM10 ({env.pm10:.1f} μg/m³) - consider indoor alternatives")
|
||||||
|
|
||||||
|
# HR recommendations
|
||||||
|
if health.heart_rate is not None and health.resting_heart_rate is not None:
|
||||||
|
elevation = health.heart_rate - health.resting_heart_rate
|
||||||
|
if elevation > 20:
|
||||||
|
if env.temperature is not None and env.temperature > 25:
|
||||||
|
recommendations.append("Elevated HR in hot weather - stay hydrated and rest if needed")
|
||||||
|
elif env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD:
|
||||||
|
recommendations.append("Elevated HR may be related to air quality - consider indoor rest")
|
||||||
|
else:
|
||||||
|
recommendations.append("Elevated HR - may indicate stress or fatigue, consider rest")
|
||||||
|
|
||||||
|
# Goal progress
|
||||||
|
if health.steps > 0:
|
||||||
|
goal_progress = (health.steps / 10000) * 100
|
||||||
|
if goal_progress < 50:
|
||||||
|
recommendations.append(f"Daily goal: {goal_progress:.0f}% complete - good time for activity")
|
||||||
|
elif goal_progress >= 100:
|
||||||
|
recommendations.append("Daily goal exceeded - excellent work!")
|
||||||
|
|
||||||
|
return recommendations
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_trend_indicators(
|
||||||
|
health: HealthMetrics,
|
||||||
|
env: EnvironmentalContext
|
||||||
|
) -> List[str]:
|
||||||
|
"""Generate trend indicators (simplified - no historical data yet)."""
|
||||||
|
indicators = []
|
||||||
|
|
||||||
|
# Activity trends (would need historical data for real trends)
|
||||||
|
if health.steps >= 10000:
|
||||||
|
indicators.append("High activity level today")
|
||||||
|
elif health.steps >= 7500:
|
||||||
|
indicators.append("Good activity level")
|
||||||
|
|
||||||
|
# Environmental impact indicators
|
||||||
|
if env.temperature is not None:
|
||||||
|
temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY
|
||||||
|
if temp_min <= env.temperature <= temp_max:
|
||||||
|
indicators.append("Optimal temperature for activity")
|
||||||
|
elif env.temperature > temp_max:
|
||||||
|
indicators.append("Warm weather may reduce activity")
|
||||||
|
|
||||||
|
if env.pm25 is not None and env.pm25 < 15:
|
||||||
|
indicators.append("Good air quality - ideal for outdoor activity")
|
||||||
|
elif env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD:
|
||||||
|
indicators.append("Moderate air quality - may affect activity")
|
||||||
|
|
||||||
|
return indicators
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def calculate(
|
||||||
|
cls,
|
||||||
|
health: HealthMetrics,
|
||||||
|
env: EnvironmentalContext
|
||||||
|
) -> HealthInsightsResult:
|
||||||
|
"""
|
||||||
|
Calculate health insights with environmental context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
health: Current health metrics
|
||||||
|
env: Environmental context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HealthInsightsResult with insights, correlations, and recommendations
|
||||||
|
"""
|
||||||
|
# Analyze individual metrics
|
||||||
|
insights = []
|
||||||
|
insights.extend(cls.analyze_heart_rate(health.heart_rate, health.resting_heart_rate, env))
|
||||||
|
insights.extend(cls.analyze_activity(health.steps, health.active_calories, env))
|
||||||
|
|
||||||
|
# Generate correlations
|
||||||
|
correlations = cls.generate_correlations(health, env)
|
||||||
|
|
||||||
|
# Generate recommendations
|
||||||
|
recommendations = cls.generate_recommendations(health, env)
|
||||||
|
|
||||||
|
# Generate trend indicators
|
||||||
|
trend_indicators = cls.generate_trend_indicators(health, env)
|
||||||
|
|
||||||
|
# Prepare health metrics dict
|
||||||
|
health_metrics = {
|
||||||
|
"steps": health.steps,
|
||||||
|
"active_calories": health.active_calories,
|
||||||
|
"heart_rate": health.heart_rate,
|
||||||
|
"resting_heart_rate": health.resting_heart_rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prepare environmental context dict
|
||||||
|
environmental_context = {
|
||||||
|
"temperature": env.temperature,
|
||||||
|
"humidity": env.humidity,
|
||||||
|
"pm25": env.pm25,
|
||||||
|
"pm10": env.pm10,
|
||||||
|
"air_quality_status": env.air_quality_status,
|
||||||
|
"weather_description": env.weather_description,
|
||||||
|
}
|
||||||
|
|
||||||
|
return HealthInsightsResult(
|
||||||
|
health_metrics=health_metrics,
|
||||||
|
environmental_context=environmental_context,
|
||||||
|
insights=insights,
|
||||||
|
correlations=correlations,
|
||||||
|
recommendations=recommendations,
|
||||||
|
trend_indicators=trend_indicators,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -10,73 +10,8 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
# Note: Device model is now managed=False, so we don't modify it in migrations
|
||||||
name="SensorType",
|
# SensorType and Sensor models are also not in current models.py
|
||||||
fields=[
|
# This migration is kept for historical reference but operations are removed
|
||||||
(
|
# to prevent errors with managed=False models
|
||||||
"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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
82
iotDashboard/migrations/0003_create_dashboard_layouts.py
Normal file
82
iotDashboard/migrations/0003_create_dashboard_layouts.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-19 22:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('iotDashboard', '0002_sensortype_remove_device_humidity_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DeviceCertificate',
|
||||||
|
fields=[
|
||||||
|
('id', models.CharField(max_length=255, primary_key=True, serialize=False)),
|
||||||
|
('certificate_pem', models.TextField()),
|
||||||
|
('private_key_pem', models.TextField(blank=True, null=True)),
|
||||||
|
('issued_at', models.DateTimeField()),
|
||||||
|
('expires_at', models.DateTimeField()),
|
||||||
|
('revoked_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'device_certificates',
|
||||||
|
'managed': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DeviceCredential',
|
||||||
|
fields=[
|
||||||
|
('id', models.CharField(max_length=255, primary_key=True, serialize=False)),
|
||||||
|
('credential_type', models.CharField(max_length=50)),
|
||||||
|
('credential_hash', models.TextField()),
|
||||||
|
('created_at', models.DateTimeField()),
|
||||||
|
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('revoked_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'device_credentials',
|
||||||
|
'managed': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Telemetry',
|
||||||
|
fields=[
|
||||||
|
('time', models.DateTimeField(primary_key=True, serialize=False)),
|
||||||
|
('device_id', models.CharField(db_column='device_id', max_length=255)),
|
||||||
|
('metric', models.CharField(max_length=255)),
|
||||||
|
('value', models.FloatField()),
|
||||||
|
('unit', models.CharField(blank=True, max_length=50, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'telemetry',
|
||||||
|
'managed': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='device',
|
||||||
|
options={'managed': False},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DashboardLayout',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(default='default', help_text="Layout name (e.g., 'default', 'mobile', 'work')", max_length=255, unique=True)),
|
||||||
|
('config', models.JSONField(help_text='Full dashboard configuration JSON')),
|
||||||
|
('is_default', models.BooleanField(default=True, help_text='Whether this is the default layout')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'dashboard_layouts',
|
||||||
|
'indexes': [models.Index(fields=['is_default'], name='dashboard_l_is_defa_033b71_idx'), models.Index(fields=['name'], name='dashboard_l_name_c36020_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='Sensor',
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='SensorType',
|
||||||
|
),
|
||||||
|
]
|
||||||
333
iotDashboard/run_suitability.py
Normal file
333
iotDashboard/run_suitability.py
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
"""
|
||||||
|
Run Suitability Calculator
|
||||||
|
|
||||||
|
Combines weather, air quality, and health data to determine if conditions are suitable for running.
|
||||||
|
Provides time-based recommendations and personalized insights.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, time
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WeatherData:
|
||||||
|
"""Weather data for run suitability analysis."""
|
||||||
|
temperature: float
|
||||||
|
apparent_temperature: float
|
||||||
|
wind_speed: float # km/h
|
||||||
|
precipitation: float # mm
|
||||||
|
rain: float # mm
|
||||||
|
weather_code: int
|
||||||
|
humidity: float # %
|
||||||
|
cloud_cover: float # %
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AirQualityData:
|
||||||
|
"""Air quality data for run suitability analysis."""
|
||||||
|
pm25: Optional[float] = None
|
||||||
|
pm10: Optional[float] = None
|
||||||
|
status: str = "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthData:
|
||||||
|
"""Health data for run suitability analysis."""
|
||||||
|
steps_today: int = 0
|
||||||
|
active_calories: int = 0
|
||||||
|
heart_rate: Optional[float] = None
|
||||||
|
resting_heart_rate: Optional[float] = None
|
||||||
|
daily_goal_steps: int = 10000 # Default goal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RunSuitabilityScore:
|
||||||
|
"""Run suitability results with breakdown."""
|
||||||
|
status: str # "GO", "MODERATE", "NO"
|
||||||
|
overall_score: float # 0-100
|
||||||
|
weather_score: float
|
||||||
|
air_quality_score: float
|
||||||
|
health_score: float
|
||||||
|
primary_reason: str
|
||||||
|
detailed_insights: List[str]
|
||||||
|
time_recommendations: List[str]
|
||||||
|
suggestions: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class RunSuitabilityCalculator:
|
||||||
|
"""Calculate run suitability from weather, air quality, and health data."""
|
||||||
|
|
||||||
|
# Optimal ranges for running
|
||||||
|
OPTIMAL_TEMP_RANGE = (10.0, 20.0) # °C - ideal for running
|
||||||
|
ACCEPTABLE_TEMP_RANGE = (5.0, 25.0) # °C - acceptable but not ideal
|
||||||
|
MAX_WIND_SPEED = 25.0 # km/h - above this is too windy
|
||||||
|
MAX_PRECIPITATION = 0.5 # mm - light drizzle OK, more is not
|
||||||
|
MAX_PM25 = 35.0 # μg/m³ - WHO unhealthy threshold
|
||||||
|
MAX_PM10 = 50.0 # μg/m³ - WHO unhealthy threshold
|
||||||
|
MODERATE_PM25 = 15.0 # μg/m³ - moderate threshold
|
||||||
|
MODERATE_PM10 = 20.0 # μg/m³ - moderate threshold
|
||||||
|
|
||||||
|
# Time-based recommendations
|
||||||
|
BEST_TIMES = [
|
||||||
|
(time(6, 0), time(8, 0), "Early morning - cool temperatures, low pollution"),
|
||||||
|
(time(18, 0), time(20, 0), "Evening - comfortable temperatures, good visibility"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_weather_score(weather: WeatherData) -> Tuple[float, List[str]]:
|
||||||
|
"""Calculate weather suitability score (0-100)."""
|
||||||
|
score = 100.0
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# Temperature scoring
|
||||||
|
temp = weather.temperature
|
||||||
|
if RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[0] <= temp <= RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[1]:
|
||||||
|
# Perfect temperature
|
||||||
|
pass
|
||||||
|
elif RunSuitabilityCalculator.ACCEPTABLE_TEMP_RANGE[0] <= temp <= RunSuitabilityCalculator.ACCEPTABLE_TEMP_RANGE[1]:
|
||||||
|
# Acceptable but not ideal
|
||||||
|
if temp < RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[0]:
|
||||||
|
deviation = RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[0] - temp
|
||||||
|
score -= deviation * 5 # -5 points per degree below optimal
|
||||||
|
issues.append(f"Cool ({temp:.1f}°C) - dress warmly")
|
||||||
|
else:
|
||||||
|
deviation = temp - RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[1]
|
||||||
|
score -= deviation * 3 # -3 points per degree above optimal
|
||||||
|
issues.append(f"Warm ({temp:.1f}°C) - stay hydrated")
|
||||||
|
else:
|
||||||
|
# Too cold or too hot
|
||||||
|
if temp < RunSuitabilityCalculator.ACCEPTABLE_TEMP_RANGE[0]:
|
||||||
|
score -= 50
|
||||||
|
issues.append(f"Too cold ({temp:.1f}°C) - not suitable for running")
|
||||||
|
else:
|
||||||
|
score -= 50
|
||||||
|
issues.append(f"Too hot ({temp:.1f}°C) - risk of heat exhaustion")
|
||||||
|
|
||||||
|
# Wind scoring
|
||||||
|
if weather.wind_speed > RunSuitabilityCalculator.MAX_WIND_SPEED:
|
||||||
|
score -= 30
|
||||||
|
issues.append(f"High wind ({weather.wind_speed:.1f} km/h) - difficult conditions")
|
||||||
|
elif weather.wind_speed > 15.0:
|
||||||
|
score -= 10
|
||||||
|
issues.append(f"Moderate wind ({weather.wind_speed:.1f} km/h) - may affect pace")
|
||||||
|
|
||||||
|
# Precipitation scoring
|
||||||
|
if weather.precipitation > RunSuitabilityCalculator.MAX_PRECIPITATION:
|
||||||
|
score -= 40
|
||||||
|
if weather.rain > 0:
|
||||||
|
issues.append(f"Rain ({weather.rain:.1f} mm) - slippery conditions")
|
||||||
|
else:
|
||||||
|
issues.append(f"Precipitation ({weather.precipitation:.1f} mm) - wet conditions")
|
||||||
|
|
||||||
|
# Weather code (snow, thunderstorms, etc.)
|
||||||
|
if weather.weather_code >= 71: # Snow
|
||||||
|
score -= 50
|
||||||
|
issues.append("Snow - unsafe for running")
|
||||||
|
elif weather.weather_code >= 95: # Thunderstorm
|
||||||
|
score -= 60
|
||||||
|
issues.append("Thunderstorm - dangerous conditions")
|
||||||
|
|
||||||
|
score = max(0, min(100, score))
|
||||||
|
return score, issues
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_air_quality_score(air_quality: AirQualityData) -> Tuple[float, List[str]]:
|
||||||
|
"""Calculate air quality suitability score (0-100)."""
|
||||||
|
score = 100.0
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# PM2.5 scoring
|
||||||
|
if air_quality.pm25 is not None:
|
||||||
|
if air_quality.pm25 > RunSuitabilityCalculator.MAX_PM25:
|
||||||
|
score -= 50
|
||||||
|
issues.append(f"Poor air quality (PM2.5: {air_quality.pm25:.1f} μg/m³) - unhealthy for exercise")
|
||||||
|
elif air_quality.pm25 > RunSuitabilityCalculator.MODERATE_PM25:
|
||||||
|
score -= 20
|
||||||
|
issues.append(f"Moderate air quality (PM2.5: {air_quality.pm25:.1f} μg/m³) - sensitive individuals should avoid")
|
||||||
|
|
||||||
|
# PM10 scoring
|
||||||
|
if air_quality.pm10 is not None:
|
||||||
|
if air_quality.pm10 > RunSuitabilityCalculator.MAX_PM10:
|
||||||
|
score -= 50
|
||||||
|
issues.append(f"Poor air quality (PM10: {air_quality.pm10:.1f} μg/m³) - unhealthy for exercise")
|
||||||
|
elif air_quality.pm10 > RunSuitabilityCalculator.MODERATE_PM10:
|
||||||
|
score -= 20
|
||||||
|
issues.append(f"Moderate air quality (PM10: {air_quality.pm10:.1f} μg/m³) - may affect breathing")
|
||||||
|
|
||||||
|
# Status-based scoring
|
||||||
|
status_lower = air_quality.status.lower()
|
||||||
|
if "unhealthy" in status_lower or "hazardous" in status_lower:
|
||||||
|
score = min(score, 30)
|
||||||
|
elif "moderate" in status_lower or "sensitive" in status_lower:
|
||||||
|
score = min(score, 70)
|
||||||
|
|
||||||
|
score = max(0, min(100, score))
|
||||||
|
return score, issues
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_health_score(health: HealthData) -> Tuple[float, List[str]]:
|
||||||
|
"""Calculate health context score (0-100)."""
|
||||||
|
score = 100.0
|
||||||
|
insights = []
|
||||||
|
|
||||||
|
# Check if user is already very active today
|
||||||
|
if health.steps_today > 15000:
|
||||||
|
score -= 10
|
||||||
|
insights.append("High activity today - consider rest or light activity")
|
||||||
|
elif health.steps_today > 10000:
|
||||||
|
insights.append(f"Good activity level ({health.steps_today:,} steps) - ready for a run")
|
||||||
|
|
||||||
|
# Check daily goal progress
|
||||||
|
goal_progress = (health.steps_today / health.daily_goal_steps) * 100 if health.daily_goal_steps > 0 else 0
|
||||||
|
if goal_progress < 50:
|
||||||
|
insights.append(f"Daily goal: {goal_progress:.0f}% complete - good time for a run")
|
||||||
|
elif goal_progress > 100:
|
||||||
|
insights.append("Daily goal exceeded - great job!")
|
||||||
|
|
||||||
|
# Heart rate context
|
||||||
|
if health.heart_rate is not None:
|
||||||
|
if health.heart_rate > 100:
|
||||||
|
score -= 15
|
||||||
|
insights.append(f"Elevated heart rate ({health.heart_rate:.0f} bpm) - may need rest")
|
||||||
|
elif health.heart_rate > 85:
|
||||||
|
score -= 5
|
||||||
|
insights.append(f"Slightly elevated HR ({health.heart_rate:.0f} bpm) - consider lighter activity")
|
||||||
|
|
||||||
|
if health.resting_heart_rate is not None and health.heart_rate is not None:
|
||||||
|
hr_elevation = health.heart_rate - health.resting_heart_rate
|
||||||
|
if hr_elevation > 20:
|
||||||
|
insights.append("Heart rate significantly elevated - may indicate stress or fatigue")
|
||||||
|
|
||||||
|
score = max(0, min(100, score))
|
||||||
|
return score, insights
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_time_recommendations(current_time: Optional[datetime] = None) -> List[str]:
|
||||||
|
"""Get time-based recommendations for running."""
|
||||||
|
if current_time is None:
|
||||||
|
current_time = datetime.now()
|
||||||
|
|
||||||
|
current_hour = current_time.hour
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
# Check if current time is in optimal range
|
||||||
|
for start_time, end_time, description in RunSuitabilityCalculator.BEST_TIMES:
|
||||||
|
if start_time.hour <= current_hour < end_time.hour:
|
||||||
|
recommendations.append(f"Current time is ideal: {description}")
|
||||||
|
else:
|
||||||
|
# Calculate hours until next optimal time
|
||||||
|
if current_hour < start_time.hour:
|
||||||
|
hours_until = start_time.hour - current_hour
|
||||||
|
recommendations.append(f"Best time in {hours_until} hours ({start_time.strftime('%I:%M %p')}): {description}")
|
||||||
|
elif current_hour >= end_time.hour:
|
||||||
|
# Next optimal time is tomorrow
|
||||||
|
next_start = RunSuitabilityCalculator.BEST_TIMES[0][0] if start_time == RunSuitabilityCalculator.BEST_TIMES[-1][0] else RunSuitabilityCalculator.BEST_TIMES[1][0]
|
||||||
|
hours_until = (24 - current_hour) + next_start.hour
|
||||||
|
recommendations.append(f"Best time in {hours_until} hours ({next_start.strftime('%I:%M %p')}): {description}")
|
||||||
|
|
||||||
|
return recommendations
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def calculate(
|
||||||
|
cls,
|
||||||
|
weather: WeatherData,
|
||||||
|
air_quality: AirQualityData,
|
||||||
|
health: HealthData,
|
||||||
|
current_time: Optional[datetime] = None
|
||||||
|
) -> RunSuitabilityScore:
|
||||||
|
"""
|
||||||
|
Calculate overall run suitability.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
weather: Weather data
|
||||||
|
air_quality: Air quality data
|
||||||
|
health: Health data
|
||||||
|
current_time: Current time for recommendations (default: now)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RunSuitabilityScore with status, scores, and insights
|
||||||
|
"""
|
||||||
|
if current_time is None:
|
||||||
|
current_time = datetime.now()
|
||||||
|
|
||||||
|
# Calculate component scores
|
||||||
|
weather_score, weather_issues = cls.calculate_weather_score(weather)
|
||||||
|
air_quality_score, air_quality_issues = cls.calculate_air_quality_score(air_quality)
|
||||||
|
health_score, health_insights = cls.calculate_health_score(health)
|
||||||
|
|
||||||
|
# Weighted overall score
|
||||||
|
overall_score = (
|
||||||
|
weather_score * 0.40 +
|
||||||
|
air_quality_score * 0.35 +
|
||||||
|
health_score * 0.25
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine status
|
||||||
|
if overall_score >= 75:
|
||||||
|
status = "GO"
|
||||||
|
elif overall_score >= 50:
|
||||||
|
status = "MODERATE"
|
||||||
|
else:
|
||||||
|
status = "NO"
|
||||||
|
|
||||||
|
# Primary reason
|
||||||
|
primary_reason = "Conditions are perfect for running!"
|
||||||
|
if status == "NO":
|
||||||
|
if weather_score < 50:
|
||||||
|
primary_reason = weather_issues[0] if weather_issues else "Weather conditions are poor"
|
||||||
|
elif air_quality_score < 50:
|
||||||
|
primary_reason = air_quality_issues[0] if air_quality_issues else "Air quality is poor"
|
||||||
|
else:
|
||||||
|
primary_reason = "Conditions are not suitable for running"
|
||||||
|
elif status == "MODERATE":
|
||||||
|
if weather_score < 70:
|
||||||
|
primary_reason = weather_issues[0] if weather_issues else "Weather conditions are moderate"
|
||||||
|
elif air_quality_score < 70:
|
||||||
|
primary_reason = air_quality_issues[0] if air_quality_issues else "Air quality is moderate"
|
||||||
|
else:
|
||||||
|
primary_reason = "Conditions are okay, but not ideal"
|
||||||
|
|
||||||
|
# Combine all insights
|
||||||
|
detailed_insights = []
|
||||||
|
detailed_insights.extend(weather_issues)
|
||||||
|
detailed_insights.extend(air_quality_issues)
|
||||||
|
detailed_insights.extend(health_insights)
|
||||||
|
|
||||||
|
# Time recommendations
|
||||||
|
time_recommendations = cls.get_time_recommendations(current_time)
|
||||||
|
|
||||||
|
# Suggestions
|
||||||
|
suggestions = []
|
||||||
|
if status == "GO":
|
||||||
|
suggestions.append("Perfect conditions - enjoy your run!")
|
||||||
|
if health.steps_today < health.daily_goal_steps * 0.5:
|
||||||
|
suggestions.append("Great time to work toward your daily step goal")
|
||||||
|
elif status == "MODERATE":
|
||||||
|
suggestions.append("Conditions are acceptable - consider shorter or easier route")
|
||||||
|
if weather_score < 70:
|
||||||
|
suggestions.append("Dress appropriately for weather conditions")
|
||||||
|
if air_quality_score < 70:
|
||||||
|
suggestions.append("Sensitive individuals should consider indoor alternatives")
|
||||||
|
else:
|
||||||
|
suggestions.append("Consider indoor workout or wait for better conditions")
|
||||||
|
if weather_score < 50:
|
||||||
|
suggestions.append("Check weather forecast for better times")
|
||||||
|
if air_quality_score < 50:
|
||||||
|
suggestions.append("Air quality should improve later - check back in a few hours")
|
||||||
|
|
||||||
|
return RunSuitabilityScore(
|
||||||
|
status=status,
|
||||||
|
overall_score=round(overall_score, 1),
|
||||||
|
weather_score=round(weather_score, 1),
|
||||||
|
air_quality_score=round(air_quality_score, 1),
|
||||||
|
health_score=round(health_score, 1),
|
||||||
|
primary_reason=primary_reason,
|
||||||
|
detailed_insights=detailed_insights,
|
||||||
|
time_recommendations=time_recommendations,
|
||||||
|
suggestions=suggestions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -50,11 +50,13 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
|
"corsheaders",
|
||||||
"iotDashboard",
|
"iotDashboard",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
@@ -150,4 +152,20 @@ REST_FRAMEWORK = {
|
|||||||
'DEFAULT_PARSER_CLASSES': [
|
'DEFAULT_PARSER_CLASSES': [
|
||||||
'rest_framework.parsers.JSONParser',
|
'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
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
# MQTT Data Generator
|
|
||||||
|
|
||||||
A Python script that simulates realistic environmental sensor data and publishes it to your MQTT broker for testing the IoT Dashboard.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
✅ **8 Environmental Metrics**: Temperature, Humidity, CO2, Pressure, Light, Noise, PM2.5, VOC
|
|
||||||
✅ **Realistic Patterns**: Daily cycles (office hours, night time)
|
|
||||||
✅ **Random Walk**: Natural sensor drift and variations
|
|
||||||
✅ **Anomaly Injection**: 5% chance of anomalies for testing alerts
|
|
||||||
✅ **Self-Correcting**: Values drift back toward optimal ranges (simulates HVAC)
|
|
||||||
✅ **TLS/mTLS Support**: Secure connections with certificates
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
pip install paho-mqtt
|
|
||||||
|
|
||||||
# Or using uv
|
|
||||||
uv pip install paho-mqtt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 1. Basic Usage (Localhost, No TLS)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/mqtt_data_generator.py --device-id office-sensor-01 --interval 5
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. With Specific Metrics
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/mqtt_data_generator.py \
|
|
||||||
--device-id lab-sensor \
|
|
||||||
--metrics temperature humidity co2 \
|
|
||||||
--interval 10
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. With TLS (Port 8883)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/mqtt_data_generator.py \
|
|
||||||
--device-id secure-sensor \
|
|
||||||
--broker localhost \
|
|
||||||
--port 8883 \
|
|
||||||
--tls \
|
|
||||||
--ca-cert infrastructure/mosquitto/certs/ca.crt \
|
|
||||||
--client-cert path/to/device.crt \
|
|
||||||
--client-key path/to/device.key \
|
|
||||||
--interval 5
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Limited Duration (Testing)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run for 5 minutes
|
|
||||||
python scripts/mqtt_data_generator.py \
|
|
||||||
--device-id test-sensor \
|
|
||||||
--duration 300 \
|
|
||||||
--interval 2
|
|
||||||
```
|
|
||||||
|
|
||||||
## Command Line Arguments
|
|
||||||
|
|
||||||
| Argument | Required | Default | Description |
|
|
||||||
|----------|----------|---------|-------------|
|
|
||||||
| `--broker` | No | localhost | MQTT broker hostname |
|
|
||||||
| `--port` | No | 1883 | MQTT broker port (8883 for TLS) |
|
|
||||||
| `--device-id` | **Yes** | - | Device ID for MQTT topics |
|
|
||||||
| `--metrics` | No | all | Specific metrics to publish |
|
|
||||||
| `--interval` | No | 5 | Publish interval in seconds |
|
|
||||||
| `--duration` | No | 0 | Run duration in seconds (0 = infinite) |
|
|
||||||
| `--tls` | No | False | Enable TLS/SSL encryption |
|
|
||||||
| `--ca-cert` | No | - | Path to CA certificate |
|
|
||||||
| `--client-cert` | No | - | Path to client certificate (mTLS) |
|
|
||||||
| `--client-key` | No | - | Path to client private key (mTLS) |
|
|
||||||
|
|
||||||
## Available Metrics
|
|
||||||
|
|
||||||
All metrics follow the standards in `services/gpt_service/METRICS_REFERENCE.md`:
|
|
||||||
|
|
||||||
- **temperature** - Indoor temperature (15-28°C)
|
|
||||||
- **humidity** - Relative humidity (20-70%)
|
|
||||||
- **co2** - Carbon dioxide concentration (400-1500 ppm)
|
|
||||||
- **pressure** - Atmospheric pressure (1000-1030 hPa)
|
|
||||||
- **light** - Illuminance (200-1000 lux)
|
|
||||||
- **noise** - Sound level (30-70 dB)
|
|
||||||
- **pm25** - Fine particulate matter (0-50 µg/m³)
|
|
||||||
- **voc** - Volatile organic compounds (0-500 ppb)
|
|
||||||
|
|
||||||
## MQTT Topic Format
|
|
||||||
|
|
||||||
Published to standard format: `devices/{device_id}/{metric}`
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- `devices/office-sensor-01/temperature`
|
|
||||||
- `devices/office-sensor-01/humidity`
|
|
||||||
- `devices/lab-sensor/co2`
|
|
||||||
|
|
||||||
## Data Patterns
|
|
||||||
|
|
||||||
### Daily Cycles
|
|
||||||
- **Business hours (9-17)**: Higher CO2, temperature, noise
|
|
||||||
- **Night time (22-6)**: Lower light, CO2, noise
|
|
||||||
- **All day**: Natural variations within ranges
|
|
||||||
|
|
||||||
### Realistic Behavior
|
|
||||||
- **Random walk**: Small incremental changes
|
|
||||||
- **Self-correcting**: HVAC-like drift toward optimal ranges
|
|
||||||
- **Anomalies**: 5% chance of spikes/drops for alert testing
|
|
||||||
|
|
||||||
### Example Values
|
|
||||||
|
|
||||||
```
|
|
||||||
[14:23:45] Iteration 1
|
|
||||||
📊 temperature: 21.34 °C -> devices/office-sensor-01/temperature
|
|
||||||
📊 humidity: 45.67 % -> devices/office-sensor-01/humidity
|
|
||||||
📊 co2: 678.0 ppm -> devices/office-sensor-01/co2
|
|
||||||
📊 pressure: 1015.23 hPa -> devices/office-sensor-01/pressure
|
|
||||||
📊 light: 456.0 lux -> devices/office-sensor-01/light
|
|
||||||
📊 noise: 42.5 dB -> devices/office-sensor-01/noise
|
|
||||||
📊 pm25: 8.3 µg/m³ -> devices/office-sensor-01/pm25
|
|
||||||
📊 voc: 120.0 ppb -> devices/office-sensor-01/voc
|
|
||||||
```
|
|
||||||
|
|
||||||
## Multi-Device Simulation
|
|
||||||
|
|
||||||
Run multiple instances with different device IDs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Terminal 1 - Office sensor
|
|
||||||
python scripts/mqtt_data_generator.py --device-id office-01 &
|
|
||||||
|
|
||||||
# Terminal 2 - Lab sensor
|
|
||||||
python scripts/mqtt_data_generator.py --device-id lab-01 &
|
|
||||||
|
|
||||||
# Terminal 3 - Warehouse sensor
|
|
||||||
python scripts/mqtt_data_generator.py --device-id warehouse-01 &
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Scenarios
|
|
||||||
|
|
||||||
### Normal Operations
|
|
||||||
```bash
|
|
||||||
python scripts/mqtt_data_generator.py \
|
|
||||||
--device-id normal-sensor \
|
|
||||||
--interval 5
|
|
||||||
```
|
|
||||||
|
|
||||||
### High-Frequency Monitoring
|
|
||||||
```bash
|
|
||||||
python scripts/mqtt_data_generator.py \
|
|
||||||
--device-id fast-sensor \
|
|
||||||
--interval 1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Limited Metrics (CO2 monitoring)
|
|
||||||
```bash
|
|
||||||
python scripts/mqtt_data_generator.py \
|
|
||||||
--device-id co2-monitor \
|
|
||||||
--metrics co2 temperature humidity \
|
|
||||||
--interval 10
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration with IoT Dashboard
|
|
||||||
|
|
||||||
1. **Register device** (if using device_manager):
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8000/devices/register \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"name":"Office Sensor","location":"Building A - Floor 2"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Start data generator** with the device ID:
|
|
||||||
```bash
|
|
||||||
python scripts/mqtt_data_generator.py --device-id <device_id>
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **View in dashboard**: Data will appear in the frontend automatically
|
|
||||||
|
|
||||||
4. **Test AI analysis**: Use the AI Insights widget to analyze patterns
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Connection Refused
|
|
||||||
- Check MQTT broker is running: `docker compose -f infrastructure/compose.yml ps`
|
|
||||||
- Verify port: 1883 (plain) or 8883 (TLS)
|
|
||||||
|
|
||||||
### TLS Certificate Errors
|
|
||||||
- Ensure CA certificate path is correct
|
|
||||||
- For mTLS, verify client cert/key match device registration
|
|
||||||
- Check certificate hasn't expired
|
|
||||||
|
|
||||||
### No Data in Dashboard
|
|
||||||
- Verify mqtt_ingestion service is running
|
|
||||||
- Check Redis stream: `redis-cli XLEN mqtt:ingestion`
|
|
||||||
- Verify db_write service is running
|
|
||||||
- Check device_id matches registered device
|
|
||||||
|
|
||||||
### High CPU Usage
|
|
||||||
- Increase `--interval` value
|
|
||||||
- Reduce number of metrics
|
|
||||||
- Run fewer instances
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- **CPU**: ~1-2% per instance at 5s interval
|
|
||||||
- **Memory**: ~20MB per instance
|
|
||||||
- **Network**: ~100 bytes per metric per publish
|
|
||||||
- **Recommended**: Max 10 instances on a single machine
|
|
||||||
|
|
||||||
## Example Output
|
|
||||||
|
|
||||||
```
|
|
||||||
============================================================
|
|
||||||
🌡️ MQTT Environmental Sensor Data Generator
|
|
||||||
============================================================
|
|
||||||
Device ID: office-sensor-01
|
|
||||||
Metrics: temperature, humidity, co2, pressure, light, noise, pm25, voc
|
|
||||||
Interval: 5s
|
|
||||||
Duration: Infinite
|
|
||||||
============================================================
|
|
||||||
✓ Connected to MQTT broker at localhost:1883
|
|
||||||
|
|
||||||
[14:23:45] Iteration 1
|
|
||||||
📊 temperature: 21.34 °C -> devices/office-sensor-01/temperature
|
|
||||||
📊 humidity: 45.67 % -> devices/office-sensor-01/humidity
|
|
||||||
📊 co2: 678.0 ppm -> devices/office-sensor-01/co2
|
|
||||||
📊 pressure: 1015.23 hPa -> devices/office-sensor-01/pressure
|
|
||||||
📊 light: 456.0 lux -> devices/office-sensor-01/light
|
|
||||||
📊 noise: 42.5 dB -> devices/office-sensor-01/noise
|
|
||||||
📊 pm25: 8.3 µg/m³ -> devices/office-sensor-01/pm25
|
|
||||||
📊 voc: 120.0 ppb -> devices/office-sensor-01/voc
|
|
||||||
|
|
||||||
[14:23:50] Iteration 2
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Stopping the Generator
|
|
||||||
|
|
||||||
- **Ctrl+C**: Graceful shutdown
|
|
||||||
- **Automatic**: If `--duration` specified
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Part of the IoT Dashboard project.
|
|
||||||
@@ -20,6 +20,8 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY --from=builder /app/.venv /app/.venv
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
|
|
||||||
|
COPY --from=builder /app/src/ /app/src/
|
||||||
|
|
||||||
COPY --from=builder /app/*.py /app/
|
COPY --from=builder /app/*.py /app/
|
||||||
|
|
||||||
RUN adduser -D -u 1000 appuser && \
|
RUN adduser -D -u 1000 appuser && \
|
||||||
|
|||||||
7
services/gpt_service/.dockerignore
Normal file
7
services/gpt_service/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.env*
|
||||||
|
.md
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.log
|
||||||
11
services/gpt_service/Dockerfile
Normal file
11
services/gpt_service/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
|
||||||
|
RUN uv sync
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uv", "run", "uvicorn","main:app", "--host", "0.0.0.0" ,"--port", "8001"]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from config import API_KEY, MODEL_NAME, PROVIDER_NAME, HOST_URL, LOG_LEVEL
|
from config import API_KEY, MODEL_NAME, PROVIDER_NAME, HOST_URL, LOG_LEVEL
|
||||||
import logging
|
import logging
|
||||||
@@ -372,3 +373,246 @@ Keep all text concise: summary under 50 words, each item under 20 words.""",
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Analysis failed: {str(e)}")
|
self.logger.error(f"Analysis failed: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def generate_daily_briefing(
|
||||||
|
self,
|
||||||
|
briefing_type: str,
|
||||||
|
current_time: str,
|
||||||
|
indoor_data: Dict[str, Any] | None = None,
|
||||||
|
outdoor_data: Dict[str, Any] | None = None,
|
||||||
|
health_data: Dict[str, Any] | None = None,
|
||||||
|
calendar_events: List[Dict[str, Any]] | None = 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 (temp, humidity, co2, etc.)
|
||||||
|
outdoor_data: Weather and air quality data
|
||||||
|
health_data: Health/fitness metrics (steps, heart rate, etc.)
|
||||||
|
calendar_events: List of upcoming calendar events
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status_line, status_emoji, insights, recommendations, context
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Build context sections
|
||||||
|
context_sections = []
|
||||||
|
|
||||||
|
# Current time context
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(current_time.replace('Z', '+00:00'))
|
||||||
|
time_str = dt.strftime("%A, %B %d at %I:%M %p")
|
||||||
|
except:
|
||||||
|
time_str = current_time
|
||||||
|
context_sections.append(f"Current Time: {time_str}")
|
||||||
|
|
||||||
|
# Indoor environment
|
||||||
|
if indoor_data:
|
||||||
|
indoor_lines = ["Indoor Environment:"]
|
||||||
|
for key, value in indoor_data.items():
|
||||||
|
if value is not None:
|
||||||
|
indoor_lines.append(f" - {key}: {value}")
|
||||||
|
context_sections.append("\n".join(indoor_lines))
|
||||||
|
|
||||||
|
# Outdoor conditions
|
||||||
|
if outdoor_data:
|
||||||
|
outdoor_lines = ["Outdoor Conditions:"]
|
||||||
|
for key, value in outdoor_data.items():
|
||||||
|
if value is not None:
|
||||||
|
outdoor_lines.append(f" - {key}: {value}")
|
||||||
|
context_sections.append("\n".join(outdoor_lines))
|
||||||
|
|
||||||
|
# Health metrics
|
||||||
|
if health_data:
|
||||||
|
health_lines = ["Health & Activity:"]
|
||||||
|
for key, value in health_data.items():
|
||||||
|
if value is not None:
|
||||||
|
health_lines.append(f" - {key}: {value}")
|
||||||
|
context_sections.append("\n".join(health_lines))
|
||||||
|
|
||||||
|
# Calendar events
|
||||||
|
if calendar_events:
|
||||||
|
cal_lines = ["Upcoming Schedule:"]
|
||||||
|
for event in calendar_events[:10]: # Limit to 10 events
|
||||||
|
start = event.get('start', 'TBD')
|
||||||
|
summary = event.get('summary', 'Untitled')
|
||||||
|
location = event.get('location', '')
|
||||||
|
loc_str = f" at {location}" if location else ""
|
||||||
|
cal_lines.append(f" - {start}: {summary}{loc_str}")
|
||||||
|
context_sections.append("\n".join(cal_lines))
|
||||||
|
|
||||||
|
context = "\n\n".join(context_sections)
|
||||||
|
|
||||||
|
# Build briefing-specific prompts
|
||||||
|
prompts = {
|
||||||
|
"schedule": f"""You are a smart wellness coach for office workers (software engineers, tech/finance professionals).
|
||||||
|
Generate a Schedule Briefing focused on calendar and activity optimization.
|
||||||
|
|
||||||
|
{context}
|
||||||
|
|
||||||
|
Environmental thresholds for reference:
|
||||||
|
- CO2 > 1000ppm: cognitive performance drops, ventilate before focused work
|
||||||
|
- PM2.5 > 35: poor outdoor air, avoid outdoor activities
|
||||||
|
- Temperature 20-24°C: optimal for focus work
|
||||||
|
- Steps goal: 10,000/day, take breaks every 2 hours
|
||||||
|
|
||||||
|
Respond in this EXACT JSON format (no markdown, just valid JSON):
|
||||||
|
{{
|
||||||
|
"status_emoji": "🟢|🟡|🔴",
|
||||||
|
"status_line": "One sentence summary of schedule/activity outlook",
|
||||||
|
"insights": [
|
||||||
|
"Actionable insight tied to specific time or event",
|
||||||
|
"Another insight about activity or scheduling",
|
||||||
|
"Insight about outdoor activity timing based on conditions"
|
||||||
|
],
|
||||||
|
"recommendations": [
|
||||||
|
"Primary recommendation with specific timing",
|
||||||
|
"Secondary recommendation"
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Tie insights to SPECIFIC upcoming events or time slots
|
||||||
|
- Suggest activity breaks during schedule gaps
|
||||||
|
- Recommend outdoor activity timing based on air quality
|
||||||
|
- Keep each insight under 25 words
|
||||||
|
- Be specific about times (e.g., "before your 2pm meeting" not "later today")
|
||||||
|
- Focus on productivity, movement, and work-life balance""",
|
||||||
|
|
||||||
|
"environment": f"""You are a smart wellness coach for office workers (software engineers, tech/finance professionals).
|
||||||
|
Generate an Environment Briefing focused on indoor workspace conditions.
|
||||||
|
|
||||||
|
{context}
|
||||||
|
|
||||||
|
Environmental thresholds and their impacts:
|
||||||
|
- CO2 400-800ppm: optimal | 800-1000ppm: acceptable | >1000ppm: ventilate (affects cognition)
|
||||||
|
- Temperature 20-22°C: optimal | 18-24°C: acceptable | outside: adjust
|
||||||
|
- Humidity 40-60%: optimal | <30%: too dry | >70%: too humid
|
||||||
|
- PM2.5 0-12: good | 12-35: moderate | >35: unhealthy
|
||||||
|
- Light 400-500 lux: optimal for desk work
|
||||||
|
|
||||||
|
Respond in this EXACT JSON format (no markdown, just valid JSON):
|
||||||
|
{{
|
||||||
|
"status_emoji": "🟢|🟡|🔴",
|
||||||
|
"status_line": "One sentence summary of workspace conditions",
|
||||||
|
"insights": [
|
||||||
|
"Insight about most important environmental factor with action",
|
||||||
|
"Insight about another condition affecting work",
|
||||||
|
"Insight about ventilation or air quality"
|
||||||
|
],
|
||||||
|
"recommendations": [
|
||||||
|
"Primary action to improve workspace",
|
||||||
|
"Secondary recommendation"
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- If CO2 is high, mention opening windows BEFORE the next meeting/focused work
|
||||||
|
- Tie environmental actions to upcoming activities when possible
|
||||||
|
- Be specific about what to do (e.g., "open window" not "improve ventilation")
|
||||||
|
- Mention how conditions affect cognitive performance
|
||||||
|
- Keep each insight under 25 words""",
|
||||||
|
|
||||||
|
"full": f"""You are a smart wellness coach for office workers (software engineers, tech/finance professionals).
|
||||||
|
Generate a comprehensive Daily Briefing combining schedule, environment, and health.
|
||||||
|
|
||||||
|
{context}
|
||||||
|
|
||||||
|
Environmental thresholds:
|
||||||
|
- CO2 > 1000ppm: cognitive performance drops
|
||||||
|
- PM2.5 > 35: poor outdoor air quality
|
||||||
|
- Temperature 20-24°C: optimal for focus
|
||||||
|
|
||||||
|
Health targets:
|
||||||
|
- 10,000 steps/day
|
||||||
|
- Movement break every 2 hours
|
||||||
|
- Heart rate zones for activity planning
|
||||||
|
|
||||||
|
Respond in this EXACT JSON format (no markdown, just valid JSON):
|
||||||
|
{{
|
||||||
|
"status_emoji": "🟢|🟡|🔴",
|
||||||
|
"status_line": "One sentence overall summary",
|
||||||
|
"insights": [
|
||||||
|
"Most important cross-domain insight (environment + schedule)",
|
||||||
|
"Health/activity insight tied to schedule",
|
||||||
|
"Environmental condition affecting upcoming work",
|
||||||
|
"Outdoor activity timing recommendation"
|
||||||
|
],
|
||||||
|
"recommendations": [
|
||||||
|
"Primary recommendation combining multiple factors",
|
||||||
|
"Secondary recommendation"
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Connect environment to schedule (e.g., "ventilate before your debugging session")
|
||||||
|
- Suggest activity timing based on air quality AND schedule gaps
|
||||||
|
- Prioritize actionable insights over status reports
|
||||||
|
- Be specific about times and actions
|
||||||
|
- Keep each insight under 25 words"""
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = prompts.get(briefing_type, prompts["full"])
|
||||||
|
|
||||||
|
system_prompt = """You are an expert wellness coach for knowledge workers. You help office workers (especially software engineers and tech/finance professionals) maintain optimal productivity and health by providing actionable, timing-specific advice.
|
||||||
|
|
||||||
|
Your insights should:
|
||||||
|
1. Be tied to specific times or upcoming events
|
||||||
|
2. Explain WHY something matters (e.g., "CO2 affects focus")
|
||||||
|
3. Give specific actions (e.g., "open the window now" not "improve air")
|
||||||
|
4. Consider work patterns (deep work, meetings, breaks)
|
||||||
|
|
||||||
|
Always respond with valid JSON only, no markdown formatting."""
|
||||||
|
|
||||||
|
self.logger.info(f"Generating {briefing_type} briefing")
|
||||||
|
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model=self.model_name,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
temperature=0.7,
|
||||||
|
max_tokens=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
result_text = response.choices[0].message.content
|
||||||
|
self.logger.info(f"Briefing generated. Tokens used: {response.usage.total_tokens}")
|
||||||
|
|
||||||
|
# Parse JSON response
|
||||||
|
import json
|
||||||
|
# Clean potential markdown wrapping
|
||||||
|
if result_text.startswith("```"):
|
||||||
|
result_text = result_text.split("```")[1]
|
||||||
|
if result_text.startswith("json"):
|
||||||
|
result_text = result_text[4:]
|
||||||
|
result_text = result_text.strip()
|
||||||
|
|
||||||
|
result = json.loads(result_text)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status_emoji": result.get("status_emoji", "🟢"),
|
||||||
|
"status_line": result.get("status_line", "Briefing generated"),
|
||||||
|
"insights": result.get("insights", []),
|
||||||
|
"recommendations": result.get("recommendations", []),
|
||||||
|
"briefing_type": briefing_type,
|
||||||
|
"generated_at": current_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
self.logger.error(f"Failed to parse briefing JSON: {e}")
|
||||||
|
self.logger.error(f"Raw response: {result_text}")
|
||||||
|
return {
|
||||||
|
"status_emoji": "⚠️",
|
||||||
|
"status_line": "Briefing generated with formatting issues",
|
||||||
|
"insights": [result_text[:200] if result_text else "Unable to generate insights"],
|
||||||
|
"recommendations": [],
|
||||||
|
"briefing_type": briefing_type,
|
||||||
|
"generated_at": current_time,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Briefing generation failed: {str(e)}")
|
||||||
|
raise
|
||||||
|
|||||||
@@ -48,6 +48,34 @@ class AnalyzeResponse(BaseModel):
|
|||||||
data_points_analyzed: int
|
data_points_analyzed: int
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEvent(BaseModel):
|
||||||
|
summary: str
|
||||||
|
start: str
|
||||||
|
end: str | None = None
|
||||||
|
location: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DailyBriefingRequest(BaseModel):
|
||||||
|
briefing_type: Literal["schedule", "environment", "full"] = Field(
|
||||||
|
default="full",
|
||||||
|
description="Type of briefing to generate"
|
||||||
|
)
|
||||||
|
current_time: str = Field(..., description="Current time in ISO format")
|
||||||
|
indoor_data: Dict[str, Any] | None = Field(None, description="Indoor environment readings")
|
||||||
|
outdoor_data: Dict[str, Any] | None = Field(None, description="Weather and air quality data")
|
||||||
|
health_data: Dict[str, Any] | None = Field(None, description="Health/fitness metrics")
|
||||||
|
calendar_events: List[CalendarEvent] | None = Field(None, description="Upcoming calendar events")
|
||||||
|
|
||||||
|
|
||||||
|
class DailyBriefingResponse(BaseModel):
|
||||||
|
status_emoji: str
|
||||||
|
status_line: str
|
||||||
|
insights: List[str]
|
||||||
|
recommendations: List[str]
|
||||||
|
briefing_type: str
|
||||||
|
generated_at: str
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
return {"status": "healthy", "service": "gpt_service"}
|
return {"status": "healthy", "service": "gpt_service"}
|
||||||
@@ -91,3 +119,39 @@ async def analyze_telemetry(request: AnalyzeRequest):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Analysis failed: {str(e)}")
|
logger.error(f"Analysis failed: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/daily-briefing", response_model=DailyBriefingResponse)
|
||||||
|
async def generate_daily_briefing(request: DailyBriefingRequest):
|
||||||
|
"""
|
||||||
|
Generate a daily briefing for office workers.
|
||||||
|
Combines environment, schedule, and health data into actionable insights.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not gpt_service:
|
||||||
|
raise HTTPException(status_code=503, detail="GPT service not initialized")
|
||||||
|
|
||||||
|
logger.info(f"Generating {request.briefing_type} briefing")
|
||||||
|
|
||||||
|
# Convert calendar events to dicts
|
||||||
|
calendar_events = None
|
||||||
|
if request.calendar_events:
|
||||||
|
calendar_events = [event.model_dump() for event in request.calendar_events]
|
||||||
|
|
||||||
|
result = await gpt_service.generate_daily_briefing(
|
||||||
|
briefing_type=request.briefing_type,
|
||||||
|
current_time=request.current_time,
|
||||||
|
indoor_data=request.indoor_data,
|
||||||
|
outdoor_data=request.outdoor_data,
|
||||||
|
health_data=request.health_data,
|
||||||
|
calendar_events=calendar_events,
|
||||||
|
)
|
||||||
|
|
||||||
|
return DailyBriefingResponse(**result)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Validation error: {str(e)}")
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Briefing generation failed: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Briefing generation failed: {str(e)}")
|
||||||
@@ -8,5 +8,6 @@ dependencies = [
|
|||||||
"fastapi>=0.121.0",
|
"fastapi>=0.121.0",
|
||||||
"openai>=2.6.1",
|
"openai>=2.6.1",
|
||||||
"pydantic>=2.12.3",
|
"pydantic>=2.12.3",
|
||||||
|
"uvicorn[standard]>=0.24.0",
|
||||||
"python-dotenv>=1.2.1",
|
"python-dotenv>=1.2.1",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY --from=builder /app/.venv /app/.venv
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
|
|
||||||
|
COPY --from=builder /app/src/ /app/src/
|
||||||
|
|
||||||
COPY --from=builder /app/*.py /app/
|
COPY --from=builder /app/*.py /app/
|
||||||
|
|
||||||
RUN adduser -D -u 1000 appuser && \
|
RUN adduser -D -u 1000 appuser && \
|
||||||
|
|||||||
199
uv.lock
generated
199
uv.lock
generated
@@ -114,6 +114,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorama"
|
name = "colorama"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -239,6 +251,7 @@ dependencies = [
|
|||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -247,6 +260,7 @@ requires-dist = [
|
|||||||
{ name = "openai", specifier = ">=2.6.1" },
|
{ name = "openai", specifier = ">=2.6.1" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.3" },
|
{ name = "pydantic", specifier = ">=2.12.3" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -299,6 +313,28 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httptools"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpx"
|
name = "httpx"
|
||||||
version = "0.28.1"
|
version = "0.28.1"
|
||||||
@@ -621,6 +657,42 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redis"
|
name = "redis"
|
||||||
version = "7.0.1"
|
version = "7.0.1"
|
||||||
@@ -781,3 +853,130 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599
|
|||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
|
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.38.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
standard = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "httptools" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||||
|
{ name = "watchfiles" },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvloop"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "watchfiles"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "15.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user