Added Calendar viewer and Environment overview with AI, Health overviewer

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

View File

@@ -1,6 +1,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route, Link, NavLink } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { WellnessStateProvider } from './hooks/useWellnessState'
import Dashboard from './pages/Dashboard'
import DeviceList from './pages/DeviceList'
import DeviceDetail from './pages/DeviceDetail'
@@ -43,7 +44,7 @@ function AppLayout({ children }: { children: React.ReactNode }) {
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<span>IoT Dashboard</span>
<span>Lyncis</span>
</Link>
</div>
@@ -81,15 +82,17 @@ function AppLayout({ children }: { children: React.ReactNode }) {
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Toaster position="top-right" />
<Routes>
<Route path="/" element={<AppLayout><Dashboard /></AppLayout>} />
<Route path="/devices" element={<AppLayout><DeviceList /></AppLayout>} />
<Route path="/devices/add" element={<AppLayout><AddDevice /></AppLayout>} />
<Route path="/devices/:id" element={<AppLayout><DeviceDetail /></AppLayout>} />
</Routes>
</BrowserRouter>
<WellnessStateProvider>
<BrowserRouter>
<Toaster position="top-right" />
<Routes>
<Route path="/" element={<AppLayout><Dashboard /></AppLayout>} />
<Route path="/devices" element={<AppLayout><DeviceList /></AppLayout>} />
<Route path="/devices/add" element={<AppLayout><AddDevice /></AppLayout>} />
<Route path="/devices/:id" element={<AppLayout><DeviceDetail /></AppLayout>} />
</Routes>
</BrowserRouter>
</WellnessStateProvider>
</QueryClientProvider>
)
}

View File

@@ -146,3 +146,150 @@ export const weatherApi = {
sensor_count: number;
}>('/weather/air_quality/', { params: { city } }),
};
// Dashboard Layout API
export const dashboardLayoutApi = {
getAll: () =>
apiClient.get<Array<{
id: number
name: string
config: any
is_default: boolean
created_at: string
updated_at: string
}>>('/dashboard-layouts/'),
getDefault: () =>
apiClient.get<{
id: number
name: string
config: any
is_default: boolean
created_at: string
updated_at: string
}>('/dashboard-layouts/default/'),
create: (data: {
name: string
config: any
is_default?: boolean
}) =>
apiClient.post('/dashboard-layouts/', data),
update: (id: number, data: {
name?: string
config?: any
is_default?: boolean
}) =>
apiClient.put(`/dashboard-layouts/${id}/`, data),
delete: (id: number) =>
apiClient.delete(`/dashboard-layouts/${id}/`),
setDefault: (id: number) =>
apiClient.post(`/dashboard-layouts/${id}/set_default/`),
}
// Wellness API
export const wellnessApi = {
getRunSuitability: (healthDeviceId: string, city: string, timeOfDay?: string) =>
apiClient.get<{
status: 'GO' | 'MODERATE' | 'NO';
overall_score: number;
scores: {
weather: number;
air_quality: number;
health: number;
};
primary_reason: string;
detailed_insights: string[];
time_recommendations: string[];
suggestions: string[];
weather_data: {
temperature: number;
wind_speed: number;
precipitation: number;
description: string;
};
air_quality_data: {
pm25: number | null;
pm10: number | null;
status: string;
};
health_data: {
steps: number;
active_calories: number;
heart_rate: number | null;
};
}>('/wellness/run_suitability/', {
params: {
health_device_id: healthDeviceId,
city,
...(timeOfDay && { time_of_day: timeOfDay }),
},
}),
getHealthInsights: (healthDeviceId: string, city?: string) =>
apiClient.get<{
health_metrics: {
steps: number;
active_calories: number;
heart_rate: number | null;
resting_heart_rate: number | null;
};
environmental_context: {
temperature: number | null;
humidity: number | null;
pm25: number | null;
pm10: number | null;
air_quality_status: string | null;
weather_description: string | null;
};
insights: Array<{
metric: string;
value: number;
context: string;
correlation: string | null;
recommendation: string | null;
}>;
correlations: string[];
recommendations: string[];
trend_indicators: string[];
}>('/wellness/health_insights/', {
params: {
health_device_id: healthDeviceId,
...(city && { city }),
},
}),
getDailyBriefing: (params: {
briefing_type: 'schedule' | 'environment' | 'full';
city: string;
health_device_id?: string;
calendar_url?: string;
calendar_range_hours?: number;
}) =>
apiClient.post<{
status_emoji: string;
status_line: string;
insights: string[];
recommendations: string[];
briefing_type: string;
generated_at: string;
context: {
indoor: Record<string, number> | null;
outdoor: Record<string, number | string | null> | null;
health: Record<string, number> | null;
calendar_event_count: number;
};
}>('/wellness/daily_briefing/', params),
};
// Calendar API
export const calendarApi = {
fetch: (calendarUrl: string, options?: { signal?: AbortSignal }) =>
apiClient.get<{ calendar_data: string }>('/calendar/fetch/', {
params: { calendar_url: calendarUrl },
signal: options?.signal,
}),
};

View File

@@ -18,8 +18,12 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
const [timeframeHours, setTimeframeHours] = useState(24)
const [widgetWidth, setWidgetWidth] = useState(1)
const [widgetHeight, setWidgetHeight] = useState(2)
const [widgetHeight, setWidgetHeight] = useState(3)
const [city, setCity] = useState('Skopje')
const [calendarUrl, setCalendarUrl] = useState('')
const [showCalendarUrl, setShowCalendarUrl] = useState(false)
const [calendarRangeHours, setCalendarRangeHours] = useState(72)
const [briefingType, setBriefingType] = useState<'schedule' | 'environment' | 'full'>('full')
// Fetch devices
const { data: devicesData } = useQuery({
@@ -44,6 +48,10 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
const devices = devicesData?.results || []
const availableMetrics = deviceMetricsData || []
// Validation helpers
const needsDevice = !['weather', 'air-quality', 'calendar', 'daily-briefing'].includes(widgetType)
const needsMetrics = !['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widgetType)
// Reset form when modal opens
useEffect(() => {
if (isOpen) {
@@ -54,7 +62,11 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
setSelectedMetrics([])
setTimeframeHours(24)
setWidgetWidth(1)
setWidgetHeight(2)
setWidgetHeight(3)
setCalendarUrl('')
setShowCalendarUrl(false)
setCalendarRangeHours(72)
setBriefingType('full')
}
}, [isOpen])
@@ -64,12 +76,31 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
}, [selectedDevices])
const handleSubmit = () => {
// Weather and air-quality widgets don't need device/metric validation
if (widgetType !== 'weather' && widgetType !== 'air-quality') {
if (selectedDevices.length === 0 || selectedMetrics.length === 0) {
alert('Please select at least one device and one metric')
return
}
// Validation
if (needsDevice && selectedDevices.length === 0) {
alert('Please select a device')
return
}
if (needsMetrics && selectedMetrics.length === 0) {
alert('Please select at least one metric')
return
}
const wellnessWidgets = ['run-suitability', 'health-stats']
if (wellnessWidgets.includes(widgetType) && !city) {
alert('Please enter a city for wellness widgets')
return
}
if (widgetType === 'calendar' && !calendarUrl.trim()) {
alert('Please provide an iCal URL')
return
}
if (widgetType === 'daily-briefing' && !city) {
alert('Please enter a city for daily briefing')
return
}
// Create title
@@ -78,6 +109,16 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
defaultTitle = `Weather - ${city}`
} else if (widgetType === 'air-quality') {
defaultTitle = `Air Quality - ${city}`
} else if (widgetType === 'run-suitability') {
defaultTitle = `Run Suitability - ${city}`
} else if (widgetType === 'health-stats') {
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
defaultTitle = `Health StaTts - ${selectedDevice?.name || 'Device'}`
} else if (widgetType === 'calendar') {
defaultTitle = 'Calendar Agenda'
} else if (widgetType === 'daily-briefing') {
const briefingLabels = { schedule: 'Schedule', environment: 'Environment', full: 'Full' }
defaultTitle = `${briefingLabels[briefingType]} Briefing`
} else {
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics)
@@ -87,8 +128,8 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
id: `widget-${Date.now()}`,
type: widgetType,
title: title || defaultTitle,
deviceIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedDevices,
metricIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedMetrics,
deviceIds: needsDevice ? selectedDevices : [],
metricIds: needsMetrics ? selectedMetrics : [],
timeframe: {
hours: timeframeHours,
},
@@ -96,13 +137,24 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
showLegend: true,
showGrid: true,
height: widgetType === 'line-chart' ? 300 : undefined,
city: widgetType === 'weather' || widgetType === 'air-quality' ? city : undefined,
city: (widgetType === 'weather' || widgetType === 'air-quality' || wellnessWidgets.includes(widgetType) || widgetType === 'daily-briefing') ? city : undefined,
},
calendar: widgetType === 'calendar' ? {
icalUrl: calendarUrl.trim(),
timeRangeHours: calendarRangeHours,
} : undefined,
briefing: widgetType === 'daily-briefing' ? {
briefingType,
city,
healthDeviceId: selectedDevices[0],
calendarUrl: calendarUrl.trim() || undefined,
calendarRangeHours: calendarRangeHours,
} : undefined,
position: {
x: 0,
y: 0,
w: widgetWidth,
h: widgetHeight,
h: ['run-suitability', 'health-stats', 'daily-briefing'].includes(widgetType) ? 2 : widgetHeight,
},
}
@@ -249,6 +301,58 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
<div className="text-xs opacity-70">Multi-factor</div>
</div>
</button>
<button
className={`btn ${widgetType === 'run-suitability' ? 'btn-primary' : 'btn-outline'} justify-start`}
onClick={() => setWidgetType('run-suitability')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<div className="text-left">
<div className="font-semibold">Run Suitability</div>
<div className="text-xs opacity-70">Go / No Go</div>
</div>
</button>
<button
className={`btn ${widgetType === 'health-stats' ? 'btn-primary' : 'btn-outline'} justify-start`}
onClick={() => setWidgetType('health-stats')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
<div className="text-left">
<div className="font-semibold">Health Stats</div>
<div className="text-xs opacity-70">Steps & HR</div>
</div>
</button>
<button
className={`btn ${widgetType === 'calendar' ? 'btn-primary' : 'btn-outline'} justify-start`}
onClick={() => setWidgetType('calendar')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10m-12 8h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<div className="text-left">
<div className="font-semibold">Calendar</div>
<div className="text-xs opacity-70">Agenda (iCal)</div>
</div>
</button>
<button
className={`btn ${widgetType === 'daily-briefing' ? 'btn-primary' : 'btn-outline'} justify-start`}
onClick={() => setWidgetType('daily-briefing')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<div className="text-left">
<div className="font-semibold">Daily Briefing</div>
<div className="text-xs opacity-70">AI Summary</div>
</div>
</button>
</div>
</div>
@@ -260,7 +364,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
className="btn btn-primary"
onClick={() => {
// Skip data source step for weather and air quality widgets
if (widgetType === 'weather' || widgetType === 'air-quality') {
if (widgetType === 'weather' || widgetType === 'air-quality' || widgetType === 'calendar' || widgetType === 'daily-briefing') {
setStep(3)
} else {
setStep(2)
@@ -274,8 +378,29 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
)}
{/* Step 2: Data Source */}
{step === 2 && widgetType !== 'weather' && widgetType !== 'air-quality' && (
{step === 2 && widgetType !== 'weather' && widgetType !== 'air-quality' && widgetType !== 'calendar' && widgetType !== 'daily-briefing' && (
<div className="space-y-4">
{/* City input for wellness widgets */}
{['run-suitability', 'health-stats'].includes(widgetType) && (
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">City (for weather/air quality)</span>
</label>
<input
type="text"
className="input input-bordered"
placeholder="Enter city name (e.g., Skopje)"
value={city}
onChange={(e) => setCity(e.target.value)}
/>
<label className="label">
<span className="label-text-alt">
Used for weather and air quality context
</span>
</label>
</div>
)}
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Select Device</span>
@@ -305,48 +430,53 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
</div>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Select Metric(s)</span>
<span className="label-text-alt">{selectedMetrics.length} selected</span>
</label>
{selectedDevices.length === 0 ? (
<div className="border rounded-lg p-4 text-center text-base-content/60">
Please select a device first
</div>
) : (
<>
{(['stat', 'gauge'].includes(widgetType)) && (
<div className="alert alert-info mb-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span className="text-sm">This widget type supports only one metric</span>
</div>
)}
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
{availableMetrics.length === 0 ? (
<div className="text-center text-base-content/60 py-4">
No metrics found for this device
</div>
) : (
availableMetrics.map((metric) => (
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
<input
type={(['stat', 'gauge'].includes(widgetType)) ? 'radio' : 'checkbox'}
name={(['stat', 'gauge'].includes(widgetType)) ? 'single-metric' : undefined}
className={(['stat', 'gauge'].includes(widgetType)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
checked={selectedMetrics.includes(metric)}
onChange={() => toggleMetric(metric)}
/>
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
</label>
))
)}
{/* Metric Selection - Skip for widgets with hardcoded metrics */}
{!['run-suitability', 'health-stats'].includes(widgetType) && (
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Select Metric(s)</span>
<span className="label-text-alt">{selectedMetrics.length} selected</span>
</label>
{selectedDevices.length === 0 ? (
<div className="border rounded-lg p-4 text-center text-base-content/60">
Please select a device first
</div>
</>
)}
</div>
) : (
<>
{(['stat', 'gauge'].includes(widgetType)) && (
<div className="alert alert-info mb-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span className="text-sm">This widget type supports only one metric</span>
</div>
)}
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
{availableMetrics.length === 0 ? (
<div className="text-center text-base-content/60 py-4">
No metrics found for this device
</div>
) : (
availableMetrics.map((metric) => (
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
<input
type={(['stat', 'gauge'].includes(widgetType)) ? 'radio' : 'checkbox'}
name={(['stat', 'gauge'].includes(widgetType)) ? 'single-metric' : undefined}
className={(['stat', 'gauge'].includes(widgetType)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
checked={selectedMetrics.includes(metric)}
onChange={() => toggleMetric(metric)}
/>
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
</label>
))
)}
</div>
</>
)}
</div>
)}
<div className="modal-action">
<button className="btn btn-ghost" onClick={() => setStep(1)}>
@@ -354,8 +484,12 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
</button>
<button
className="btn btn-primary"
onClick={() => setStep(3)}
disabled={selectedDevices.length === 0 || selectedMetrics.length === 0}
disabled={
(needsDevice && selectedDevices.length === 0) ||
(needsMetrics && selectedMetrics.length === 0)
}
>
Next
</button>
@@ -402,6 +536,194 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
/>
</div>
</>
) : widgetType === 'calendar' ? (
<>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">iCal URL</span>
</label>
<div className="flex gap-2">
<input
type={showCalendarUrl ? 'text' : 'password'}
className="input input-bordered flex-1"
placeholder="https://calendar.google.com/calendar/ical/..."
value={calendarUrl}
onChange={(e) => setCalendarUrl(e.target.value)}
/>
<button
type="button"
className="btn btn-outline"
onClick={() => setShowCalendarUrl((prev) => !prev)}
>
{showCalendarUrl ? 'Hide' : 'Show'}
</button>
</div>
<label className="label">
<span className="label-text-alt">Link is saved with the widget configuration</span>
</label>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Agenda Range</span>
</label>
<select
className="select select-bordered"
value={calendarRangeHours}
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
>
<option value={24}>Next 24 hours</option>
<option value={72}>Next 3 days</option>
<option value={168}>Next 7 days</option>
</select>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Widget Title (Optional)</span>
</label>
<input
type="text"
className="input input-bordered"
placeholder="Calendar Agenda"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
</>
) : widgetType === 'daily-briefing' ? (
<>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Briefing Type</span>
</label>
<div className="flex gap-2">
<button
type="button"
className={`btn flex-1 ${briefingType === 'schedule' ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setBriefingType('schedule')}
>
📅 Schedule
</button>
<button
type="button"
className={`btn flex-1 ${briefingType === 'environment' ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setBriefingType('environment')}
>
🌡 Environment
</button>
<button
type="button"
className={`btn flex-1 ${briefingType === 'full' ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setBriefingType('full')}
>
Full
</button>
</div>
<label className="label">
<span className="label-text-alt">
{briefingType === 'schedule' && 'Focus on your calendar and daily activity'}
{briefingType === 'environment' && 'Focus on indoor/outdoor conditions for productivity'}
{briefingType === 'full' && 'Complete briefing with all insights combined'}
</span>
</label>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">City (for weather/air quality)</span>
</label>
<input
type="text"
className="input input-bordered"
placeholder="Enter city name (e.g., Skopje)"
value={city}
onChange={(e) => setCity(e.target.value)}
/>
</div>
{(briefingType === 'schedule' || briefingType === 'full') && (
<>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Calendar URL (Optional)</span>
</label>
<div className="flex gap-2">
<input
type={showCalendarUrl ? 'text' : 'password'}
className="input input-bordered flex-1"
placeholder="https://calendar.google.com/calendar/ical/..."
value={calendarUrl}
onChange={(e) => setCalendarUrl(e.target.value)}
/>
<button
type="button"
className="btn btn-outline"
onClick={() => setShowCalendarUrl((prev) => !prev)}
>
{showCalendarUrl ? 'Hide' : 'Show'}
</button>
</div>
<label className="label">
<span className="label-text-alt">Optional: Include calendar events in your briefing</span>
</label>
</div>
{calendarUrl && (
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Calendar Range</span>
</label>
<select
className="select select-bordered"
value={calendarRangeHours}
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
>
<option value={24}>Next 24 hours</option>
<option value={72}>Next 3 days</option>
<option value={168}>Next 7 days</option>
</select>
</div>
)}
{devices.length > 0 && (
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Health Device (Optional)</span>
</label>
<select
className="select select-bordered"
value={selectedDevices[0] || ''}
onChange={(e) => setSelectedDevices(e.target.value ? [e.target.value] : [])}
>
<option value="">No health device</option>
{devices.map((device) => (
<option key={device.id} value={device.id}>
{device.name} ({device.location || 'No location'})
</option>
))}
</select>
<label className="label">
<span className="label-text-alt">Optional: Include step count and activity data</span>
</label>
</div>
)}
</>
)}
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Widget Title (Optional)</span>
</label>
<input
type="text"
className="input input-bordered"
placeholder={`${briefingType.charAt(0).toUpperCase() + briefingType.slice(1)} Briefing`}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
</>
) : (
// Original configuration for sensor-based widgets
<>
@@ -478,14 +800,23 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
<div className="text-sm">
<div className="font-semibold">Widget Summary</div>
<div>Type: <span className="badge badge-sm">{widgetType}</span></div>
<div>Device: {devices.find(d => d.id === selectedDevices[0])?.name}</div>
<div>Metrics: {selectedMetrics.join(', ')}</div>
<div>Device: {needsDevice ? (devices.find(d => d.id === selectedDevices[0])?.name || 'Not selected') : 'Not required'}</div>
<div>Metrics: {needsMetrics ? (selectedMetrics.join(', ') || 'Not selected') : 'Not required'}</div>
<div>Size: {widgetWidth} × {widgetHeight}</div>
{widgetType === 'calendar' && (
<div>Range: Next {calendarRangeHours}h</div>
)}
{widgetType === 'daily-briefing' && (
<div>Briefing: {briefingType.charAt(0).toUpperCase() + briefingType.slice(1)}</div>
)}
</div>
</div>
<div className="modal-action">
<button className="btn btn-ghost" onClick={() => setStep(2)}>
<button
className="btn btn-ghost"
onClick={() => setStep((widgetType === 'weather' || widgetType === 'air-quality' || widgetType === 'calendar' || widgetType === 'daily-briefing') ? 1 : 2)}
>
Back
</button>
<button className="btn btn-primary" onClick={handleSubmit}>

View File

@@ -12,44 +12,140 @@ interface EditWidgetModalProps {
export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: EditWidgetModalProps) {
const [title, setTitle] = useState('')
const [selectedDeviceId, setSelectedDeviceId] = useState('')
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
const [timeframeHours, setTimeframeHours] = useState(24)
const [widgetWidth, setWidgetWidth] = useState(1)
const [widgetHeight, setWidgetHeight] = useState(2)
const [calendarUrl, setCalendarUrl] = useState('')
const [showCalendarUrl, setShowCalendarUrl] = useState(false)
const [calendarRangeHours, setCalendarRangeHours] = useState(72)
const [briefingType, setBriefingType] = useState<'schedule' | 'environment' | 'full'>('full')
const [city, setCity] = useState('Skopje')
// Fetch metrics for the widget's device
const { data: deviceMetricsData } = useQuery({
queryKey: ['device-metrics', widget?.deviceIds[0]],
// Fetch all devices
const { data: devicesData } = useQuery({
queryKey: ['devices'],
queryFn: async () => {
if (!widget?.deviceIds[0]) return []
const response = await devicesApi.getMetrics(widget.deviceIds[0])
return response.data.metrics
const response = await devicesApi.getAll()
return response.data.results
},
enabled: !!widget?.deviceIds[0] && isOpen,
enabled: isOpen,
})
const availableMetrics = deviceMetricsData || []
// Fetch metrics for the selected device
const { data: deviceMetricsData } = useQuery({
queryKey: ['device-metrics', selectedDeviceId],
queryFn: async () => {
if (!selectedDeviceId) return []
const response = await devicesApi.getMetrics(selectedDeviceId)
return response.data.metrics
},
enabled: !!selectedDeviceId && isOpen,
})
const availableMetrics = Array.isArray(deviceMetricsData) ? deviceMetricsData : []
const devices = Array.isArray(devicesData) ? devicesData : []
// Load widget data when modal opens
useEffect(() => {
if (isOpen && widget) {
setTitle(widget.title || '')
setSelectedDeviceId(widget.deviceIds[0] || widget.briefing?.healthDeviceId || '')
setSelectedMetrics(widget.metricIds || [])
setTimeframeHours(widget.timeframe?.hours || 24)
setWidgetWidth(widget.position?.w || 1)
setWidgetHeight(widget.position?.h || 2)
setCalendarUrl(widget.calendar?.icalUrl || widget.briefing?.calendarUrl || '')
setCalendarRangeHours(widget.calendar?.timeRangeHours || widget.briefing?.calendarRangeHours || 72)
setShowCalendarUrl(false)
setBriefingType((widget.briefing?.briefingType as 'schedule' | 'environment' | 'full') || 'full')
setCity(widget.visualization?.city || widget.briefing?.city || 'Skopje')
}
}, [isOpen, widget])
// Reset metrics when device changes
useEffect(() => {
if (selectedDeviceId && widget && selectedDeviceId !== widget.deviceIds[0]) {
setSelectedMetrics([])
}
}, [selectedDeviceId, widget])
const handleSubmit = () => {
if (!widget || selectedMetrics.length === 0) {
if (!widget) return
const needsDevice = !['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type)
const needsMetrics = !['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type)
if (widget.type === 'calendar') {
if (!calendarUrl.trim()) {
alert('Please provide an iCal URL')
return
}
onSave(widget.id, {
title,
calendar: {
icalUrl: calendarUrl.trim(),
timeRangeHours: calendarRangeHours,
},
position: {
...widget.position,
x: widget.position?.x || 0,
y: widget.position?.y || 0,
w: widgetWidth,
h: widgetHeight,
},
})
onClose()
return
}
if (widget.type === 'daily-briefing') {
if (!city.trim()) {
alert('Please enter a city')
return
}
onSave(widget.id, {
title,
visualization: {
...widget.visualization,
city,
},
briefing: {
briefingType,
city,
healthDeviceId: selectedDeviceId || undefined,
calendarUrl: calendarUrl.trim() || undefined,
calendarRangeHours: calendarRangeHours,
},
position: {
...widget.position,
x: widget.position?.x || 0,
y: widget.position?.y || 0,
w: widgetWidth,
h: widgetHeight,
},
})
onClose()
return
}
if (needsDevice && (!selectedDeviceId)) {
alert('Please select a device')
return
}
if (needsMetrics && selectedMetrics.length === 0) {
alert('Please select at least one metric')
return
}
onSave(widget.id, {
title,
metricIds: selectedMetrics,
deviceIds: needsDevice ? [selectedDeviceId] : [],
metricIds: needsMetrics ? selectedMetrics : [],
timeframe: {
hours: timeframeHours,
},
@@ -102,6 +198,27 @@ export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: Edi
<div className="badge badge-lg badge-primary">{widget.type}</div>
</div>
{/* Device Selection */}
{!['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type) && (
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Device</span>
</label>
<select
className="select select-bordered"
value={selectedDeviceId}
onChange={(e) => setSelectedDeviceId(e.target.value)}
>
<option value="">Select a device</option>
{devices.map((device) => (
<option key={device.id} value={device.id}>
{device.name} ({device.location || 'No location'})
</option>
))}
</select>
</div>
)}
{/* Title */}
<div className="form-control">
<label className="label">
@@ -117,58 +234,216 @@ export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: Edi
</div>
{/* Metrics */}
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Select Metric(s)</span>
<span className="label-text-alt">{selectedMetrics.length} selected</span>
</label>
{(['stat', 'gauge'].includes(widget.type)) && (
<div className="alert alert-info mb-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span className="text-sm">This widget type supports only one metric</span>
</div>
)}
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
{availableMetrics.length === 0 ? (
<div className="text-center text-base-content/60 py-4">
Loading metrics...
{!['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type) && (
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Select Metric(s)</span>
<span className="label-text-alt">{selectedMetrics.length} selected</span>
</label>
{(['stat', 'gauge'].includes(widget.type)) && (
<div className="alert alert-info mb-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span className="text-sm">This widget type supports only one metric</span>
</div>
) : (
availableMetrics.map((metric) => (
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
<input
type={(['stat', 'gauge'].includes(widget.type)) ? 'radio' : 'checkbox'}
name={(['stat', 'gauge'].includes(widget.type)) ? 'single-metric' : undefined}
className={(['stat', 'gauge'].includes(widget.type)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
checked={selectedMetrics.includes(metric)}
onChange={() => toggleMetric(metric)}
/>
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
</label>
))
)}
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
{!selectedDeviceId ? (
<div className="text-center text-base-content/60 py-4">
Please select a device first
</div>
) : availableMetrics.length === 0 ? (
<div className="text-center text-base-content/60 py-4">
Loading metrics...
</div>
) : (
availableMetrics.map((metric) => (
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
<input
type={(['stat', 'gauge'].includes(widget.type)) ? 'radio' : 'checkbox'}
name={(['stat', 'gauge'].includes(widget.type)) ? 'single-metric' : undefined}
className={(['stat', 'gauge'].includes(widget.type)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
checked={selectedMetrics.includes(metric)}
onChange={() => toggleMetric(metric)}
/>
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
</label>
))
)}
</div>
</div>
</div>
)}
{/* Time Range */}
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Time Range</span>
</label>
<select
className="select select-bordered"
value={timeframeHours}
onChange={(e) => setTimeframeHours(Number(e.target.value))}
>
<option value={1}>Last 1 hour</option>
<option value={6}>Last 6 hours</option>
<option value={24}>Last 24 hours</option>
<option value={168}>Last 7 days</option>
<option value={720}>Last 30 days</option>
</select>
</div>
{!['calendar', 'daily-briefing'].includes(widget.type) && (
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Time Range</span>
</label>
<select
className="select select-bordered"
value={timeframeHours}
onChange={(e) => setTimeframeHours(Number(e.target.value))}
>
<option value={1}>Last 1 hour</option>
<option value={6}>Last 6 hours</option>
<option value={24}>Last 24 hours</option>
<option value={168}>Last 7 days</option>
<option value={720}>Last 30 days</option>
</select>
</div>
)}
{widget.type === 'calendar' && (
<>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">iCal URL</span>
</label>
<div className="flex gap-2">
<input
type={showCalendarUrl ? 'text' : 'password'}
className="input input-bordered flex-1"
placeholder="https://calendar.google.com/calendar/ical/..."
value={calendarUrl}
onChange={(e) => setCalendarUrl(e.target.value)}
/>
<button
type="button"
className="btn btn-outline"
onClick={() => setShowCalendarUrl((prev) => !prev)}
>
{showCalendarUrl ? 'Hide' : 'Show'}
</button>
</div>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Agenda Range</span>
</label>
<select
className="select select-bordered"
value={calendarRangeHours}
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
>
<option value={24}>Next 24 hours</option>
<option value={72}>Next 3 days</option>
<option value={168}>Next 7 days</option>
</select>
</div>
</>
)}
{widget.type === 'daily-briefing' && (
<>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Briefing Type</span>
</label>
<div className="flex gap-2">
<button
type="button"
className={`btn flex-1 ${briefingType === 'schedule' ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setBriefingType('schedule')}
>
📅 Schedule
</button>
<button
type="button"
className={`btn flex-1 ${briefingType === 'environment' ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setBriefingType('environment')}
>
🌡 Environment
</button>
<button
type="button"
className={`btn flex-1 ${briefingType === 'full' ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setBriefingType('full')}
>
Full
</button>
</div>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">City</span>
</label>
<input
type="text"
className="input input-bordered"
placeholder="Enter city name (e.g., Skopje)"
value={city}
onChange={(e) => setCity(e.target.value)}
/>
</div>
{(briefingType === 'schedule' || briefingType === 'full') && (
<>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Calendar URL (Optional)</span>
</label>
<div className="flex gap-2">
<input
type={showCalendarUrl ? 'text' : 'password'}
className="input input-bordered flex-1"
placeholder="https://calendar.google.com/calendar/ical/..."
value={calendarUrl}
onChange={(e) => setCalendarUrl(e.target.value)}
/>
<button
type="button"
className="btn btn-outline"
onClick={() => setShowCalendarUrl((prev) => !prev)}
>
{showCalendarUrl ? 'Hide' : 'Show'}
</button>
</div>
</div>
{calendarUrl && (
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Calendar Range</span>
</label>
<select
className="select select-bordered"
value={calendarRangeHours}
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
>
<option value={24}>Next 24 hours</option>
<option value={72}>Next 3 days</option>
<option value={168}>Next 7 days</option>
</select>
</div>
)}
{devices.length > 0 && (
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Health Device (Optional)</span>
</label>
<select
className="select select-bordered"
value={selectedDeviceId || ''}
onChange={(e) => setSelectedDeviceId(e.target.value)}
>
<option value="">No health device</option>
{devices.map((device) => (
<option key={device.id} value={device.id}>
{device.name} ({device.location || 'No location'})
</option>
))}
</select>
</div>
)}
</>
)}
</>
)}
{/* Size */}
<div className="grid grid-cols-2 gap-4">

View File

@@ -7,6 +7,38 @@ interface AiInsightWidgetProps {
config: WidgetConfig
}
interface TrendSummary {
status: 'excellent' | 'good' | 'fair' | 'poor'
summary: string
trends: Array<{
metric: string
direction: 'improving' | 'stable' | 'degrading'
description: string
}>
comfort_score: {
rating: number
description: string
}
patterns: string[]
recommendations: string[]
forecast: string
}
interface AnomalyDetection {
status: 'normal' | 'warning' | 'critical'
summary: string
anomalies: Array<{
metric: string
severity: 'low' | 'medium' | 'high' | 'critical'
description: string
value: string
expected: string
}>
impacts: string[]
actions: string[]
root_causes: string[]
}
export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
const { deviceIds, metricIds, timeframe, title } = config
const [promptType, setPromptType] = useState<'trend_summary' | 'anomaly_detection'>('trend_summary')
@@ -40,6 +72,52 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
refetch()
}
// Parse JSON analysis if it's a string
const parsedAnalysis = analysis?.analysis ? (() => {
try {
return typeof analysis.analysis === 'string'
? JSON.parse(analysis.analysis)
: analysis.analysis
} catch {
return null // If parsing fails, return null to show raw text
}
})() : null
const isTrendSummary = promptType === 'trend_summary' && parsedAnalysis
const isAnomalyDetection = promptType === 'anomaly_detection' && parsedAnalysis
const getStatusColor = (status: string) => {
switch (status) {
case 'excellent': return 'badge-success'
case 'good': return 'badge-info'
case 'fair': return 'badge-warning'
case 'poor': return 'badge-error'
case 'normal': return 'badge-success'
case 'warning': return 'badge-warning'
case 'critical': return 'badge-error'
default: return 'badge-ghost'
}
}
const getDirectionIcon = (direction: string) => {
switch (direction) {
case 'improving': return '↑'
case 'degrading': return '↓'
case 'stable': return '→'
default: return '•'
}
}
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'critical': return 'badge-error'
case 'high': return 'badge-warning'
case 'medium': return 'badge-warning badge-outline'
case 'low': return 'badge-info'
default: return 'badge-ghost'
}
}
return (
<div className="card bg-base-100 shadow">
<div className="card-body">
@@ -99,20 +177,239 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
{analysis && showAnalysis && !isLoading && (
<div className="space-y-4 mt-4">
<div className="flex items-center justify-between">
<div className="badge badge-primary badge-lg">
{promptType === 'trend_summary' ? 'Trend Analysis' : 'Anomaly Detection'}
</div>
<div className="text-xs text-base-content/50">
{analysis.data_points_analyzed} data points analyzed
</div>
</div>
<div className="divider my-2"></div>
<div className="prose max-w-none">
<div className="whitespace-pre-wrap text-sm leading-relaxed max-h-96 overflow-y-auto">
{analysis.analysis}
</div>
</div>
{/* Structured Display for Trend Summary */}
{isTrendSummary && parsedAnalysis && (
<>
<div className="flex items-center justify-between">
<div className={`badge badge-lg ${getStatusColor(parsedAnalysis.status)}`}>
{parsedAnalysis.status.toUpperCase()}
</div>
<div className="text-xs text-base-content/50">
{analysis.data_points_analyzed} data points
</div>
</div>
{/* Summary */}
<div className="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<div className="font-bold">Summary</div>
<div className="text-sm">{parsedAnalysis.summary}</div>
</div>
</div>
{/* Comfort Score */}
{parsedAnalysis.comfort_score && (
<div className="card bg-base-200">
<div className="card-body p-4">
<div className="flex items-center gap-3">
<div className="radial-progress text-primary" style={{ "--value": parsedAnalysis.comfort_score.rating } as React.CSSProperties}>
{parsedAnalysis.comfort_score.rating}
</div>
<div>
<div className="font-bold">Comfort Score</div>
<div className="text-sm text-base-content/70">{parsedAnalysis.comfort_score.description}</div>
</div>
</div>
</div>
</div>
)}
{/* Trends */}
{parsedAnalysis.trends && parsedAnalysis.trends.length > 0 && (
<div>
<h4 className="font-bold text-sm mb-2">Trends</h4>
<div className="space-y-2">
{parsedAnalysis.trends.map((trend: TrendSummary['trends'][0], i: number) => (
<div key={i} className="card bg-base-200">
<div className="card-body p-3">
<div className="flex items-start gap-2">
<span className="text-lg">{getDirectionIcon(trend.direction)}</span>
<div className="flex-1">
<div className="font-semibold text-sm">{trend.metric}</div>
<div className="text-xs text-base-content/70">{trend.description}</div>
</div>
<div className="badge badge-sm">{trend.direction}</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Patterns */}
{parsedAnalysis.patterns && parsedAnalysis.patterns.length > 0 && (
<div>
<h4 className="font-bold text-sm mb-2">Patterns Detected</h4>
<ul className="space-y-1">
{parsedAnalysis.patterns.map((pattern: string, i: number) => (
<li key={i} className="text-sm flex items-start gap-2">
<span className="text-primary mt-0.5"></span>
<span>{pattern}</span>
</li>
))}
</ul>
</div>
)}
{/* Recommendations */}
{parsedAnalysis.recommendations && parsedAnalysis.recommendations.length > 0 && (
<div>
<h4 className="font-bold text-sm mb-2">Recommendations</h4>
<div className="space-y-2">
{parsedAnalysis.recommendations.map((rec: string, i: number) => (
<div key={i} className="alert alert-success alert-sm">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm">{rec}</span>
</div>
))}
</div>
</div>
)}
{/* Forecast */}
{parsedAnalysis.forecast && (
<div className="alert">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
<div>
<div className="font-bold text-sm">Forecast</div>
<div className="text-xs">{parsedAnalysis.forecast}</div>
</div>
</div>
)}
</>
)}
{/* Structured Display for Anomaly Detection */}
{isAnomalyDetection && parsedAnalysis && (
<>
<div className="flex items-center justify-between">
<div className={`badge badge-lg ${getStatusColor(parsedAnalysis.status)}`}>
{parsedAnalysis.status.toUpperCase()}
</div>
<div className="text-xs text-base-content/50">
{analysis.data_points_analyzed} data points
</div>
</div>
{/* Summary */}
<div className={`alert ${parsedAnalysis.status === 'critical' ? 'alert-error' : parsedAnalysis.status === 'warning' ? 'alert-warning' : 'alert-success'}`}>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<div className="font-bold">Summary</div>
<div className="text-sm">{parsedAnalysis.summary}</div>
</div>
</div>
{/* Anomalies */}
{parsedAnalysis.anomalies && parsedAnalysis.anomalies.length > 0 && (
<div>
<h4 className="font-bold text-sm mb-2">Anomalies Detected</h4>
<div className="space-y-2">
{parsedAnalysis.anomalies.map((anomaly: AnomalyDetection['anomalies'][0], i: number) => (
<div key={i} className="card bg-base-200 border-l-4 border-error">
<div className="card-body p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-sm">{anomaly.metric}</span>
<span className={`badge badge-sm ${getSeverityColor(anomaly.severity)}`}>
{anomaly.severity}
</span>
</div>
<div className="text-xs text-base-content/70 mb-1">{anomaly.description}</div>
<div className="text-xs">
<span className="text-error font-semibold">Current: {anomaly.value}</span>
{' • '}
<span className="text-base-content/60">Expected: {anomaly.expected}</span>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Impacts */}
{parsedAnalysis.impacts && parsedAnalysis.impacts.length > 0 && (
<div>
<h4 className="font-bold text-sm mb-2">Potential Impacts</h4>
<ul className="space-y-1">
{parsedAnalysis.impacts.map((impact: string, i: number) => (
<li key={i} className="text-sm flex items-start gap-2">
<span className="text-warning mt-0.5"></span>
<span>{impact}</span>
</li>
))}
</ul>
</div>
)}
{/* Actions */}
{parsedAnalysis.actions && parsedAnalysis.actions.length > 0 && (
<div>
<h4 className="font-bold text-sm mb-2">Recommended Actions</h4>
<div className="space-y-2">
{parsedAnalysis.actions.map((action: string, i: number) => (
<div key={i} className="alert alert-warning alert-sm">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="text-sm">{action}</span>
</div>
))}
</div>
</div>
)}
{/* Root Causes */}
{parsedAnalysis.root_causes && parsedAnalysis.root_causes.length > 0 && (
<div>
<h4 className="font-bold text-sm mb-2">Possible Root Causes</h4>
<ul className="space-y-1">
{parsedAnalysis.root_causes.map((cause: string, i: number) => (
<li key={i} className="text-sm flex items-start gap-2">
<span className="text-info mt-0.5"></span>
<span>{cause}</span>
</li>
))}
</ul>
</div>
)}
</>
)}
{/* Fallback: Raw Text Display */}
{!parsedAnalysis && (
<>
<div className="flex items-center justify-between">
<div className="badge badge-primary badge-lg">
{promptType === 'trend_summary' ? 'Trend Analysis' : 'Anomaly Detection'}
</div>
<div className="text-xs text-base-content/50">
{analysis.data_points_analyzed} data points analyzed
</div>
</div>
<div className="divider my-2"></div>
<div className="prose max-w-none">
<div className="whitespace-pre-wrap text-sm leading-relaxed max-h-96 overflow-y-auto">
{analysis.analysis}
</div>
</div>
</>
)}
<div className="flex justify-end gap-2 mt-4">
<button
className="btn btn-ghost btn-sm"

View File

@@ -0,0 +1,308 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { AxiosError } from 'axios'
import ICAL from 'ical.js'
import { calendarApi } from '../../api'
import type { WidgetConfig } from '../../hooks'
import './widget-styles.css'
const REFRESH_INTERVAL_MS = 5 * 60 * 1000
const MAX_EVENTS = 25
interface CalendarWidgetProps {
config: WidgetConfig
}
interface AgendaEvent {
id: string
summary: string
start: Date
end: Date
location?: string | null
description?: string | null
isAllDay: boolean
}
const dayFormatter = new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
})
const timeFormatter = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit',
})
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
function formatDayLabel(date: Date) {
const today = new Date()
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate())
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const diffMs = startOfDate.getTime() - startOfToday.getTime()
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
if (Math.abs(diffDays) <= 1) {
return relativeFormatter.format(diffDays, 'day')
}
return dayFormatter.format(date)
}
function formatEventRange(event: AgendaEvent) {
const sameDay = event.start.toDateString() === event.end.toDateString()
const dayLabel = formatDayLabel(event.start)
if (event.isAllDay) {
return `${dayLabel} - All day`
}
if (sameDay) {
return `${dayLabel} - ${timeFormatter.format(event.start)} to ${timeFormatter.format(event.end)}`
}
return `${dayLabel} ${timeFormatter.format(event.start)} -> ${formatDayLabel(event.end)} ${timeFormatter.format(event.end)}`
}
function createAgendaEvents(
component: ICAL.Component,
windowStart: Date,
windowEnd: Date,
): AgendaEvent[] {
const event = new ICAL.Event(component)
const results: AgendaEvent[] = []
const eventTemplate = {
summary: event.summary || 'Untitled event',
location: event.location || null,
description: event.description || null,
}
const addOccurrence = (start: ICAL.Time, end?: ICAL.Time | null) => {
const jsStart = start.toJSDate()
const jsEnd = (end || start).toJSDate()
if (jsEnd < windowStart || jsStart > windowEnd) {
return
}
results.push({
id: `${event.uid || event.summary}-${jsStart.toISOString()}`,
summary: eventTemplate.summary,
location: eventTemplate.location,
description: eventTemplate.description,
start: jsStart,
end: jsEnd,
isAllDay: start.isDate,
})
}
const overlapWindowStart = new Date(windowStart.getTime() - 24 * 60 * 60 * 1000)
const iteratorStart = ICAL.Time.fromJSDate(overlapWindowStart)
if (event.isRecurring()) {
const iterator = event.iterator(iteratorStart)
let next = iterator.next()
while (next) {
const occurrence = event.getOccurrenceDetails(next)
addOccurrence(occurrence.startDate, occurrence.endDate)
const jsStart = occurrence.startDate.toJSDate()
if (jsStart > windowEnd) {
break
}
next = iterator.next()
}
} else {
addOccurrence(event.startDate, event.endDate)
}
return results
}
export default function CalendarWidget({ config }: CalendarWidgetProps) {
const calendarConfig = config.calendar
const [events, setEvents] = useState<AgendaEvent[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const abortRef = useRef<AbortController | null>(null)
const requestIdRef = useRef(0)
const fetchEvents = useCallback(async () => {
if (!calendarConfig?.icalUrl) {
setEvents([])
setError('Calendar URL is missing')
return
}
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const requestId = requestIdRef.current + 1
requestIdRef.current = requestId
setIsLoading(true)
setError(null)
try {
const response = await calendarApi.fetch(calendarConfig.icalUrl, {
signal: controller.signal,
})
const raw = response.data?.calendar_data
if (!raw) {
throw new Error('Calendar feed was empty')
}
const parsed = ICAL.parse(raw)
const comp = new ICAL.Component(parsed)
const vevents = comp.getAllSubcomponents('vevent') || []
const now = new Date()
const windowStart = new Date(now.getTime() - 30 * 60 * 1000) // keep events that started recently
const windowEnd = new Date(now.getTime() + (calendarConfig.timeRangeHours || 72) * 60 * 60 * 1000)
const agendaEvents = vevents
.flatMap((vevent) => createAgendaEvents(vevent, windowStart, windowEnd))
.sort((a, b) => a.start.getTime() - b.start.getTime())
.slice(0, MAX_EVENTS)
setEvents(agendaEvents)
setLastUpdated(new Date())
} catch (err) {
if (controller.signal.aborted) {
return
}
const axiosError = err as AxiosError<{ error?: string; detail?: string }>
const message = axiosError?.response?.data?.error
|| axiosError?.response?.data?.detail
|| axiosError?.message
|| 'Failed to load calendar'
setError(message)
setEvents([])
} finally {
if (!controller.signal.aborted && requestId === requestIdRef.current) {
setIsLoading(false)
}
}
}, [calendarConfig?.icalUrl, calendarConfig?.timeRangeHours])
useEffect(() => {
if (!calendarConfig?.icalUrl) {
return
}
fetchEvents()
const interval = setInterval(fetchEvents, REFRESH_INTERVAL_MS)
return () => {
clearInterval(interval)
abortRef.current?.abort()
}
}, [calendarConfig?.icalUrl, fetchEvents])
const handleManualRefresh = () => {
fetchEvents()
}
if (!calendarConfig) {
return (
<div className="widget-card card bg-base-100 h-full">
<div className="card-body p-4 text-sm">
<h2 className="card-title text-sm mb-2">{config.title || 'Calendar'}</h2>
<p className="opacity-70">
Configure an iCal URL to see your agenda.
</p>
</div>
</div>
)
}
const rangeLabel = `Next ${calendarConfig.timeRangeHours || 72}h`
return (
<div className="widget-card card bg-base-100 h-full">
<div className="card-body p-3 h-full flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-xs uppercase tracking-wide opacity-60">Agenda</p>
<h2 className="card-title text-sm leading-tight">{config.title || 'Calendar'}</h2>
<p className="text-xs opacity-60">{rangeLabel}</p>
</div>
<div className="flex flex-col items-end gap-1">
<button
type="button"
className="btn btn-xs btn-outline"
onClick={handleManualRefresh}
disabled={isLoading}
>
{isLoading ? 'Refreshing...' : 'Refresh'}
</button>
{lastUpdated && (
<span className="text-[10px] opacity-60">
Updated {timeFormatter.format(lastUpdated)}
</span>
)}
</div>
</div>
{error && (
<div className="alert alert-error text-xs">
<span>{error}</span>
</div>
)}
{!error && events.length === 0 && !isLoading && (
<div className="flex-1 flex items-center justify-center text-sm opacity-60 text-center">
No upcoming events in this window.
</div>
)}
{isLoading && events.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<span className="loading loading-spinner"></span>
</div>
)}
{events.length > 0 && (
<ul className="flex-1 overflow-y-auto divide-y divide-base-200">
{events.map((event) => (
<li key={event.id} className="py-2">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold leading-tight">
{event.summary || 'Untitled event'}
</p>
<p className="text-xs opacity-70">
{formatEventRange(event)}
</p>
{event.location && (
<p className="text-[11px] opacity-70 mt-1 flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 11c1.38 0 2.5-1.12 2.5-2.5S13.38 6 12 6s-2.5 1.12-2.5 2.5S10.62 11 12 11zm0 0c-4 0-5 4-5 4v.5a2.5 2.5 0 002.5 2.5h5a2.5 2.5 0 002.5-2.5V15s-1-4-5-4z"
/>
</svg>
{event.location}
</p>
)}
</div>
<div className="text-xs font-semibold text-right whitespace-nowrap">
{event.isAllDay ? 'All day' : timeFormatter.format(event.start)}
</div>
</div>
</li>
))}
</ul>
)}
</div>
</div>
)
}

View File

@@ -1,8 +1,61 @@
import type { ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { devicesApi } from '../../api'
import type { WidgetConfig } from '../../hooks'
import './widget-styles.css'
type IconProps = { className?: string }
const IconBase = ({ className, children }: IconProps & { children: ReactNode }) => (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
)
const ThermometerIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<path d="M14 14.5V5a2 2 0 00-4 0v9.5a3.5 3.5 0 104 0z" />
<line x1="12" y1="8" x2="12" y2="11" />
</IconBase>
)
const DropletIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<path d="M12 3.5s-4 5-4 8.5a4 4 0 108 0c0-3.5-4-8.5-4-8.5z" />
</IconBase>
)
const AirQualityIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<path d="M4 12h9a3 3 0 10-3-3" />
<path d="M6 17h8a3 3 0 11-3 3" />
</IconBase>
)
const AcousticIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<path d="M5 9v6h3l4 4V5l-4 4H5z" />
<path d="M16 9a4 4 0 010 6" />
<path d="M18 7a6 6 0 010 10" />
</IconBase>
)
const LightIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<path d="M12 3a5 5 0 00-3 9v3h6v-3a5 5 0 00-3-9z" />
<path d="M10 18h4" />
<path d="M10 21h4" />
</IconBase>
)
interface ComfortIndexWidgetProps {
config: WidgetConfig
}
@@ -85,7 +138,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
<div className="flex items-center gap-1.5">
<span className="text-sm">🌡</span>
<ThermometerIcon className="w-4 h-4" />
<span className="truncate">Temperature</span>
</div>
<span className={`font-bold ${getScoreColor(data.components.temperature)}`}>
@@ -95,7 +148,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
<div className="flex items-center gap-1.5">
<span className="text-sm">💧</span>
<DropletIcon className="w-4 h-4" />
<span className="truncate">Humidity</span>
</div>
<span className={`font-bold ${getScoreColor(data.components.humidity)}`}>
@@ -105,7 +158,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
<div className="flex items-center gap-1.5">
<span className="text-sm">🌬</span>
<AirQualityIcon className="w-4 h-4" />
<span className="truncate">Air Quality</span>
</div>
<span className={`font-bold ${getScoreColor(data.components.air_quality)}`}>
@@ -115,7 +168,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
<div className="flex items-center gap-1.5">
<span className="text-sm">🔊</span>
<AcousticIcon className="w-4 h-4" />
<span className="truncate">Acoustic</span>
</div>
<span className={`font-bold ${getScoreColor(data.components.acoustic)}`}>
@@ -125,7 +178,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
<div className="flex items-center justify-between p-2 bg-base-200 rounded col-span-2">
<div className="flex items-center gap-1.5">
<span className="text-sm">💡</span>
<LightIcon className="w-4 h-4" />
<span className="truncate">Lighting</span>
</div>
<span className={`font-bold ${getScoreColor(data.components.light)}`}>
@@ -137,7 +190,10 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
{/* Suggestions */}
{data.suggestions.length > 0 && (
<div className="mt-auto">
<div className="text-xs font-semibold mb-1">💡 Suggestions</div>
<div className="flex items-center gap-1 text-xs font-semibold mb-1">
<LightIcon className="w-3.5 h-3.5" />
<span>Suggestions</span>
</div>
<div className="space-y-1 max-h-20 overflow-y-auto">
{data.suggestions.map((suggestion, i) => (
<div key={i} className="text-xs bg-warning/10 p-1 rounded border-l-2 border-warning">

View File

@@ -0,0 +1,266 @@
import { useState, useCallback } from 'react'
import { wellnessApi } from '../../api'
import type { WidgetConfig } from '../../hooks'
import './widget-styles.css'
interface DailyBriefingWidgetProps {
config: WidgetConfig
}
interface BriefingData {
status_emoji: string
status_line: string
insights: string[]
recommendations: string[]
briefing_type: string
generated_at: string
context: {
indoor: Record<string, number> | null
outdoor: Record<string, number | string | null> | null
health: Record<string, number> | null
calendar_event_count: number
}
}
const CalendarIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)
const BuildingIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
)
const ClipboardIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
)
const LightbulbIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
)
const StatusIcon = ({ status }: { status: string }) => {
if (status.includes('good') || status.includes('great') || status.includes('optimal')) {
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
}
if (status.includes('warning') || status.includes('moderate') || status.includes('attention')) {
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
)
}
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
}
const BRIEFING_TYPES = [
{ value: 'schedule', label: 'Schedule', Icon: CalendarIcon, description: 'Calendar & activity focus' },
{ value: 'environment', label: 'Environment', Icon: BuildingIcon, description: 'Workspace conditions' },
{ value: 'full', label: 'Full', Icon: ClipboardIcon, description: 'Complete overview' },
] as const
type BriefingType = typeof BRIEFING_TYPES[number]['value']
const timeFormatter = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit',
})
export default function DailyBriefingWidget({ config }: DailyBriefingWidgetProps) {
const briefingConfig = config.briefing
const [briefingType, setBriefingType] = useState<BriefingType>(
(briefingConfig?.briefingType as BriefingType) || 'full'
)
const [briefingData, setBriefingData] = useState<BriefingData | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const city = config.visualization?.city || briefingConfig?.city || 'Skopje'
const healthDeviceId = config.deviceIds?.[0] || briefingConfig?.healthDeviceId
const calendarUrl = briefingConfig?.calendarUrl
const generateBriefing = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const response = await wellnessApi.getDailyBriefing({
briefing_type: briefingType,
city,
health_device_id: healthDeviceId,
calendar_url: calendarUrl,
calendar_range_hours: briefingConfig?.calendarRangeHours || 24,
})
setBriefingData(response.data)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to generate briefing'
setError(message)
} finally {
setIsLoading(false)
}
}, [briefingType, city, healthDeviceId, calendarUrl, briefingConfig?.calendarRangeHours])
const getBriefingTypeInfo = (type: BriefingType) => {
return BRIEFING_TYPES.find(t => t.value === type) || BRIEFING_TYPES[2]
}
const CurrentIcon = getBriefingTypeInfo(briefingType).Icon
// No config state - show setup message
if (!city) {
return (
<div className="widget-card card bg-base-100 h-full">
<div className="card-body p-4">
<h2 className="card-title text-sm">{config.title || 'Daily Briefing'}</h2>
<p className="text-sm opacity-70">
Configure a city to generate briefings.
</p>
</div>
</div>
)
}
return (
<div className="widget-card card bg-base-100 h-full flex flex-col">
<div className="card-body p-3 flex-1 flex flex-col gap-2 min-h-0">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-xs uppercase tracking-wide opacity-60 flex items-center gap-1">
<CurrentIcon /> {getBriefingTypeInfo(briefingType).label} Briefing
</p>
<h2 className="card-title text-sm leading-tight">{config.title || 'Daily Briefing'}</h2>
</div>
</div>
{/* Briefing Type Selector */}
<div className="flex gap-1">
{BRIEFING_TYPES.map((type) => (
<button
key={type.value}
type="button"
className={`btn btn-xs flex-1 gap-1 ${
briefingType === type.value ? 'btn-primary' : 'btn-outline'
}`}
onClick={() => setBriefingType(type.value)}
title={type.description}
>
<type.Icon /> {type.label}
</button>
))}
</div>
{/* Generate Button or Content */}
{!briefingData && !isLoading && (
<div className="flex-1 flex flex-col items-center justify-center gap-3">
<p className="text-sm opacity-60 text-center">
Get AI-powered insights for your day
</p>
<button
type="button"
className="btn btn-primary"
onClick={generateBriefing}
disabled={isLoading}
>
Generate Briefing
</button>
</div>
)}
{/* Loading State */}
{isLoading && (
<div className="flex-1 flex flex-col items-center justify-center gap-2">
<span className="loading loading-spinner loading-lg"></span>
<p className="text-sm opacity-60">Analyzing your environment...</p>
</div>
)}
{/* Error State */}
{error && !isLoading && (
<div className="flex-1 flex flex-col gap-2">
<div className="alert alert-error text-xs">
<span>{error}</span>
</div>
<button
type="button"
className="btn btn-sm btn-outline"
onClick={generateBriefing}
>
Try Again
</button>
</div>
)}
{/* Briefing Content */}
{briefingData && !isLoading && (
<div className="flex-1 flex flex-col gap-2 overflow-y-auto">
{/* Status Line */}
<div className="bg-base-200 rounded-lg p-2">
<div className="flex items-center gap-2">
<StatusIcon status={briefingData.status_line.toLowerCase()} />
<p className="text-sm font-medium">{briefingData.status_line}</p>
</div>
</div>
{/* Insights */}
{briefingData.insights.length > 0 && (
<div className="space-y-1.5">
{briefingData.insights.map((insight, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<span className="text-primary mt-0.5"></span>
<span className="opacity-90">{insight}</span>
</div>
))}
</div>
)}
{/* Recommendations */}
{briefingData.recommendations.length > 0 && (
<div className="bg-primary/10 rounded-lg p-2 mt-auto">
<div className="flex items-start gap-2">
<LightbulbIcon />
<div className="space-y-1">
{briefingData.recommendations.map((rec, i) => (
<p key={i} className="text-sm opacity-90">{rec}</p>
))}
</div>
</div>
</div>
)}
{/* Footer with timestamp and refresh */}
<div className="flex items-center justify-between pt-1 border-t border-base-200 mt-auto">
<span className="text-[10px] opacity-50">
Generated {timeFormatter.format(new Date(briefingData.generated_at))}
</span>
<button
type="button"
className="btn btn-xs btn-ghost"
onClick={generateBriefing}
disabled={isLoading}
>
Refresh
</button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,178 @@
import { useQuery } from '@tanstack/react-query'
import { wellnessApi } from '../../api'
import { useWellnessState } from '../../hooks/useWellnessState'
import type { WidgetConfig } from '../../hooks'
import './widget-styles.css'
interface HealthStatsWidgetProps {
config: WidgetConfig
}
export default function HealthStatsWidget({ config }: HealthStatsWidgetProps) {
const { healthDeviceId, city } = useWellnessState()
// Use device from config or shared state
const deviceId = config.deviceIds[0] || healthDeviceId
const widgetCity = (config.visualization as Record<string, unknown>)?.city as string || city
const { data, isLoading, error } = useQuery({
queryKey: ['health-insights', deviceId, widgetCity],
queryFn: async () => {
if (!deviceId) {
throw new Error('Device ID is required')
}
const response = await wellnessApi.getHealthInsights(deviceId, widgetCity || undefined)
return response.data
},
refetchInterval: 60000, // Refresh every minute
enabled: !!deviceId,
})
if (isLoading) {
return (
<div className="widget-card card bg-base-100 h-full">
<div className="card-body flex items-center justify-center">
<span className="loading loading-spinner loading-lg"></span>
</div>
</div>
)
}
if (error) {
return (
<div className="widget-card card bg-base-100 h-full">
<div className="card-body flex items-center justify-center">
<div className="alert alert-error text-xs">
<span>
{error instanceof Error ? error.message : 'Failed to load health data'}
</span>
</div>
{!deviceId && (
<p className="text-xs text-base-content/60 mt-2">Select a health device</p>
)}
</div>
</div>
)
}
if (!data) return null
const StepIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
)
const FireIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z" />
</svg>
)
const HeartIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
)
const CheckIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)
const WarningIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
)
const stats = [
{
label: 'Steps',
value: data.health_metrics.steps,
unit: '',
icon: <StepIcon />,
color: 'text-primary'
},
{
label: 'Calories',
value: data.health_metrics.active_calories,
unit: 'kcal',
icon: <FireIcon />,
color: 'text-secondary'
},
{
label: 'Heart Rate',
value: data.health_metrics.heart_rate,
unit: 'bpm',
icon: <HeartIcon />,
color: 'text-error'
},
]
// Find insights for each stat
const getInsightForMetric = (metric: string) => {
return data.insights.find(i => i.metric === metric)
}
return (
<div className="widget-card card bg-base-100 h-full flex flex-col">
<div className="card-body p-3 flex-1 min-h-0">
<h2 className="card-title text-sm mb-3">{config.title}</h2>
{/* Health Stats */}
<div className="space-y-3">
{stats.map((stat, i) => {
const insight = getInsightForMetric(stat.label)
const hasValue = stat.value !== null && stat.value !== undefined
return (
<div key={i} className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={stat.color}>{stat.icon}</span>
<span className="text-sm opacity-70">{stat.label}</span>
</div>
<div className="flex items-baseline gap-1">
<span className={`text-xl font-bold ${stat.color}`}>
{hasValue ? Math.round(Number(stat.value)).toLocaleString() : '—'}
</span>
{hasValue && <span className="text-xs opacity-50">{stat.unit}</span>}
</div>
</div>
{/* Context/Correlation */}
{insight && (
<div className="text-xs space-y-0.5 ml-7">
{insight.context && (
<div className="opacity-70">{insight.context}</div>
)}
{insight.correlation && (
<div className="text-warning opacity-80 flex items-center gap-1">
<WarningIcon />
{insight.correlation}
</div>
)}
</div>
)}
</div>
)
})}
</div>
{/* Recommendations */}
{data.recommendations.length > 0 && (
<div className="mt-2 pt-2 border-t border-base-300">
<div className="text-xs">
<div className="flex items-start gap-1.5">
<span className="text-success"><CheckIcon /></span>
<span className="opacity-80">{data.recommendations[0]}</span>
</div>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { useMemo, memo } from 'react'
import {
LineChart,
Line,
@@ -17,15 +17,39 @@ interface LineChartWidgetProps {
config: WidgetConfig
}
// Optimized date formatter - cache formatters to avoid recreating
const timeFormatter = new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
})
const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
// Helper to format date efficiently
function formatTime(date: Date): string {
return timeFormatter.format(date)
}
function formatDateTime(date: Date): string {
return dateTimeFormatter.format(date)
}
// Helper component to fetch data for a single metric
function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: WidgetConfig['timeframe']) {
// Fetch data for each metric (React allows hooks in arrays when count is stable)
// Only fetch data for metrics that exist (up to 5)
const metric1 = useTelemetrySeries({
deviceId,
metric: metricIds[0] || '',
hours: timeframe.hours,
startTime: timeframe.startTime,
endTime: timeframe.endTime,
limit: 500, // Limit data points for performance
})
const metric2 = useTelemetrySeries({
@@ -34,6 +58,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi
hours: timeframe.hours,
startTime: timeframe.startTime,
endTime: timeframe.endTime,
limit: 500,
enabled: metricIds.length > 1,
})
const metric3 = useTelemetrySeries({
@@ -42,6 +68,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi
hours: timeframe.hours,
startTime: timeframe.startTime,
endTime: timeframe.endTime,
limit: 500,
enabled: metricIds.length > 2,
})
const metric4 = useTelemetrySeries({
@@ -50,6 +78,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi
hours: timeframe.hours,
startTime: timeframe.startTime,
endTime: timeframe.endTime,
limit: 500,
enabled: metricIds.length > 3,
})
const metric5 = useTelemetrySeries({
@@ -58,6 +88,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi
hours: timeframe.hours,
startTime: timeframe.startTime,
endTime: timeframe.endTime,
limit: 500,
enabled: metricIds.length > 4,
})
const queries = [metric1, metric2, metric3, metric4, metric5].slice(0, metricIds.length)
@@ -65,7 +97,7 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi
return { queries, metricIds }
}
export default function LineChartWidget({ config }: LineChartWidgetProps) {
function LineChartWidget({ config }: LineChartWidgetProps) {
const { deviceIds, metricIds, timeframe, visualization } = config
const deviceId = deviceIds[0]
@@ -74,64 +106,83 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
const isLoading = queries.some((q) => q.isLoading)
const error = queries.find((q) => q.error)?.error
// Combine data from all metrics into a single chart dataset
// Combine data from all metrics into a single chart dataset (optimized)
const chartData = useMemo(() => {
if (queries.length === 0 || !queries[0]?.data || queries[0].data.length === 0) return []
// Create a map of time -> { time, fullDateTime, metric1, metric2, ... }
const timeMap = new Map<string, Record<string, number | string>>()
// Limit total data points for performance (max 300 points)
const MAX_POINTS = 300
const totalPoints = queries.reduce((sum, q) => sum + (q.data?.length || 0), 0)
const shouldDownsample = totalPoints > MAX_POINTS
const step = shouldDownsample ? Math.ceil(totalPoints / MAX_POINTS) : 1
// Create a map of timestamp -> data point (using timestamp as key for better performance)
const timeMap = new Map<number, Record<string, number | string>>()
queries.forEach((query, index) => {
const metric = metricIds[index]
if (!query.data) return
if (!query.data || query.data.length === 0) return
query.data.forEach((point) => {
const date = new Date(point.time)
// Process data points efficiently (with downsampling if needed)
query.data.forEach((point, pointIndex) => {
// Skip points if downsampling
if (shouldDownsample && pointIndex % step !== 0) return
// Short time for X-axis display (just time)
const timeStr = date.toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
})
const timestamp = new Date(point.time).getTime()
// Full date/time for tooltip
const fullDateTime = date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
const key = date.getTime().toString()
if (!timeMap.has(key)) {
timeMap.set(key, {
time: timeStr,
fullDateTime: fullDateTime,
timestamp: date.getTime()
if (!timeMap.has(timestamp)) {
const date = new Date(timestamp)
timeMap.set(timestamp, {
time: formatTime(date),
fullDateTime: formatDateTime(date),
timestamp
})
}
const entry = timeMap.get(key)!
const entry = timeMap.get(timestamp)!
entry[metric] = point.value
})
})
// Sort by timestamp
return Array.from(timeMap.values()).sort((a, b) => {
// Sort by timestamp and convert to array
const result = Array.from(timeMap.values()).sort((a, b) => {
return (a.timestamp as number) - (b.timestamp as number)
})
return result
}, [queries, metricIds])
const colors = visualization?.colors || [
'#3b82f6', // blue
'#10b981', // green
'#f59e0b', // amber
'#ef4444', // red
'#8b5cf6', // purple
'#ec4899', // pink
]
// Memoize colors to avoid recreating array
const colors = useMemo(() =>
visualization?.colors || [
'#3b82f6', // blue
'#10b981', // green
'#f59e0b', // amber
'#ef4444', // red
'#8b5cf6', // purple
'#ec4899', // pink
],
[visualization?.colors]
)
// Memoize lines to avoid recreating on every render
const lines = useMemo(() =>
metricIds.map((metric, index) => (
<Line
key={metric}
type="monotone"
dataKey={metric}
stroke={colors[index % colors.length]}
strokeWidth={2}
dot={false} // Disable dots for better performance
activeDot={{ r: 4 }}
connectNulls={true}
name={formatMetricName(metric)}
isAnimationActive={false} // Disable animations for better performance
/>
)),
[metricIds, colors]
)
if (isLoading) {
return (
@@ -190,7 +241,11 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
{config.title || metricIds.map(formatMetricName).join(' & ')}
</h3>
<ResponsiveContainer width="100%" height={visualization?.height || 280}>
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 50 }}>
<LineChart
data={chartData}
margin={{ top: 5, right: 20, left: 0, bottom: 50 }}
syncId="dashboard-charts" // Sync charts for better performance
>
{visualization?.showGrid !== false && (
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
)}
@@ -200,6 +255,7 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
angle={-45}
textAnchor="end"
height={50}
interval="preserveStartEnd" // Reduce number of ticks
/>
<YAxis tick={{ fontSize: 12 }} />
<Tooltip
@@ -216,22 +272,13 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
formatter={(value: number) => [value.toFixed(2)]}
/>
{visualization?.showLegend !== false && <Legend />}
{metricIds.map((metric, index) => (
<Line
key={metric}
type="monotone"
dataKey={metric}
stroke={colors[index % colors.length]}
strokeWidth={2}
dot={{ r: 2, strokeWidth: 0 }}
activeDot={{ r: 4 }}
connectNulls={true}
name={formatMetricName(metric)}
/>
))}
{lines}
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
}
// Memoize the component to prevent unnecessary re-renders
export default memo(LineChartWidget)

View File

@@ -0,0 +1,162 @@
import { useQuery } from '@tanstack/react-query'
import { wellnessApi } from '../../api'
import { useWellnessState } from '../../hooks/useWellnessState'
import type { WidgetConfig } from '../../hooks'
import './widget-styles.css'
const CheckIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)
const StepIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
)
interface RunSuitabilityWidgetProps {
config: WidgetConfig
}
export default function RunSuitabilityWidget({ config }: RunSuitabilityWidgetProps) {
const { healthDeviceId, city } = useWellnessState()
// Use device from config or shared state
const deviceId = config.deviceIds[0] || healthDeviceId
const widgetCity = (config.visualization as Record<string, unknown>)?.city as string || city
const { data, isLoading, error } = useQuery({
queryKey: ['run-suitability', deviceId, widgetCity],
queryFn: async () => {
if (!deviceId || !widgetCity) {
throw new Error('Device ID and city are required')
}
const response = await wellnessApi.getRunSuitability(deviceId, widgetCity)
return response.data
},
refetchInterval: 300000, // Refresh every 5 minutes
enabled: !!deviceId && !!widgetCity,
})
if (isLoading) {
return (
<div className="widget-card card bg-base-100 h-full">
<div className="card-body flex items-center justify-center">
<span className="loading loading-spinner loading-lg"></span>
</div>
</div>
)
}
if (error || !data) {
return (
<div className="widget-card card bg-base-100 h-full">
<div className="card-body flex flex-col items-center justify-center text-center gap-2">
<div className="alert alert-error text-xs">
<span>
{error instanceof Error ? error.message : 'No data available'}
</span>
</div>
{(!deviceId || !widgetCity) && (
<p className="text-xs text-base-content/60">
{!deviceId && 'Select a health device'}
{!deviceId && !widgetCity && ' and '}
{!widgetCity && 'Select a city'}
</p>
)}
</div>
</div>
)
}
const getStatusColor = (status: string) => {
switch (status) {
case 'GO': return 'text-success'
case 'MODERATE': return 'text-warning'
case 'NO': return 'text-error'
default: return 'text-base-content'
}
}
const getBgColor = (status: string) => {
switch (status) {
case 'GO': return 'bg-success/10 border-success/20'
case 'MODERATE': return 'bg-warning/10 border-warning/20'
case 'NO': return 'bg-error/10 border-error/20'
default: return 'bg-base-200'
}
}
return (
<div className="widget-card card bg-base-100 h-full flex flex-col">
<div className="card-body p-3 flex flex-col gap-2 flex-1 min-h-0">
<h2 className="card-title text-sm mb-2">{config.title}</h2>
{/* Status Badge */}
<div className="flex flex-col items-center justify-center text-center gap-2">
<div className={`w-20 h-20 rounded-full flex items-center justify-center border-4 ${getStatusColor(data.status)} ${getBgColor(data.status)}`}>
<span className={`text-2xl font-black ${getStatusColor(data.status)}`}>
{data.status}
</span>
</div>
{/* Primary Reason */}
<div className="space-y-1">
<p className="font-medium text-sm">{data.primary_reason}</p>
{/* Score Breakdown */}
<div className="flex gap-2 text-xs text-base-content/60 justify-center">
<span>Weather: {data.scores.weather.toFixed(0)}</span>
<span></span>
<span>Air: {data.scores.air_quality.toFixed(0)}</span>
<span></span>
<span>Health: {data.scores.health.toFixed(0)}</span>
</div>
</div>
</div>
{/* Detailed Insights */}
{data.detailed_insights.length > 0 && (
<div className="space-y-1 mt-1">
<div className="text-xs font-semibold opacity-70">Conditions:</div>
<ul className="text-xs space-y-0.5">
{data.detailed_insights.slice(0, 2).map((insight, i) => (
<li key={i} className="opacity-80"> {insight}</li>
))}
</ul>
</div>
)}
{/* Time Recommendations */}
{data.time_recommendations.length > 0 && (
<div className="space-y-1 mt-1">
<div className="text-xs font-semibold opacity-70">Best Time:</div>
<div className="text-xs opacity-80"> {data.time_recommendations[0]}</div>
</div>
)}
{/* Suggestions */}
{data.suggestions.length > 0 && (
<div className="mt-1 pt-1 border-t border-base-300">
<div className="text-xs">
<div className="flex items-start gap-1.5">
<span className="text-success"><CheckIcon /></span>
<span className="opacity-80">{data.suggestions[0]}</span>
</div>
</div>
</div>
)}
{/* Quick Health Stats */}
<div className="text-xs text-base-content/60 flex gap-2 justify-center pt-1 border-t border-base-300 flex-shrink-0 mt-auto">
<span className="flex items-center gap-1">
<StepIcon />
{data.health_data.steps.toLocaleString()} steps
</span>
</div>
</div>
</div>
)
}

View File

@@ -1,7 +1,118 @@
import type { ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import type { WidgetConfig } from '../../hooks'
import { weatherApi } from '../../api'
type IconProps = {
className?: string
}
const IconBase = ({ className, children }: IconProps & { children: ReactNode }) => (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
)
const SunIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<circle cx="12" cy="12" r="4" />
<line x1="12" y1="2" x2="12" y2="5" />
<line x1="12" y1="19" x2="12" y2="22" />
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34" />
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78" />
<line x1="2" y1="12" x2="5" y2="12" />
<line x1="19" y1="12" x2="22" y2="12" />
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66" />
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22" />
</IconBase>
)
const CloudIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<path d="M5 15a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 15H5z" />
</IconBase>
)
const PartlyCloudyIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<circle cx="8" cy="8" r="3" />
<path d="M5 17a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 17H5z" />
</IconBase>
)
const FogIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<path d="M4 10h11a3 3 0 000-6 4.5 4.5 0 00-8.91 1" />
<line x1="3" y1="15" x2="17" y2="15" />
<line x1="5" y1="19" x2="19" y2="19" />
</IconBase>
)
const RainIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<path d="M5 15a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 15H5z" />
<line x1="8" y1="17" x2="8" y2="21" />
<line x1="12" y1="17" x2="12" y2="22" />
<line x1="16" y1="17" x2="16" y2="21" />
</IconBase>
)
const SnowIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<path d="M5 14a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 14H5z" />
<line x1="11" y1="16" x2="11" y2="22" />
<line x1="8.5" y1="18" x2="13.5" y2="20" />
<line x1="8.5" y1="20" x2="13.5" y2="18" />
</IconBase>
)
const ThunderIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<path d="M5 15a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 15H5z" />
<polyline points="12 16 10 20 14 20 12 24" />
</IconBase>
)
const ThermometerIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<path d="M14 14.5V5a2 2 0 00-4 0v9.5a3.5 3.5 0 104 0z" />
<line x1="12" y1="8" x2="12" y2="11" />
</IconBase>
)
const DropletIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<path d="M12 3.5s-4 5-4 8.5a4 4 0 108 0c0-3.5-4-8.5-4-8.5z" />
</IconBase>
)
const WindIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<path d="M3 12h9a3 3 0 10-3-3" />
<path d="M5 18h11a3 3 0 11-3 3" />
</IconBase>
)
const CloudCoverIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<path d="M6 17a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0119 17H6z" />
</IconBase>
)
const RainDropIcon = ({ className }: IconProps) => (
<IconBase className={className}>
<path d="M7 14a5 5 0 0010 0c0-4-5-9-5-9s-5 5-5 9z" />
</IconBase>
)
interface WeatherWidgetProps {
config: WidgetConfig
}
@@ -45,19 +156,18 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
if (!weather) return null
// Weather code to icon mapping
const getWeatherIcon = (code: number) => {
if (code === 0 || code === 1) return '☀️' // Clear/Mainly clear
if (code === 2) return '⛅' // Partly cloudy
if (code === 3) return '☁️' // Overcast
if (code >= 45 && code <= 48) return '🌫️' // Fog
if (code >= 51 && code <= 55) return '🌦️' // Drizzle
if (code >= 61 && code <= 65) return '🌧️' // Rain
if (code >= 71 && code <= 77) return '🌨️' // Snow
if (code >= 80 && code <= 82) return '🌧️' // Rain showers
if (code >= 85 && code <= 86) return '🌨️' // Snow showers
if (code >= 95) return '⛈️' // Thunderstorm
return '🌡️'
if (code === 0 || code === 1) return <SunIcon className="w-16 h-16 text-warning" />
if (code === 2) return <PartlyCloudyIcon className="w-16 h-16 text-primary" />
if (code === 3) return <CloudIcon className="w-16 h-16 text-primary" />
if (code >= 45 && code <= 48) return <FogIcon className="w-16 h-16 text-primary" />
if (code >= 51 && code <= 55) return <RainIcon className="w-16 h-16 text-primary" />
if (code >= 61 && code <= 65) return <RainIcon className="w-16 h-16 text-primary" />
if (code >= 71 && code <= 77) return <SnowIcon className="w-16 h-16 text-primary" />
if (code >= 80 && code <= 82) return <RainIcon className="w-16 h-16 text-primary" />
if (code >= 85 && code <= 86) return <SnowIcon className="w-16 h-16 text-primary" />
if (code >= 95) return <ThunderIcon className="w-16 h-16 text-primary" />
return <ThermometerIcon className="w-16 h-16 text-primary" />
}
return (
@@ -66,7 +176,7 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
<h2 className="card-title text-sm truncate">{config.title}</h2>
<div className="flex flex-col items-center justify-center flex-1">
{/* Weather Icon */}
<div className="text-5xl mb-1">{getWeatherIcon(weather.weather_code)}</div>
<div className="mb-1 text-primary">{getWeatherIcon(weather.weather_code)}</div>
{/* Temperature */}
<div className="text-3xl font-bold">{weather.temperature.toFixed(1)}°C</div>
@@ -82,20 +192,20 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
{/* Additional Info */}
<div className="grid grid-cols-2 gap-2 mt-2 w-full text-xs">
<div className="flex items-center gap-2">
<span className="opacity-60">💧</span>
<DropletIcon className="w-4 h-4 opacity-70" />
<span>{weather.humidity}%</span>
</div>
<div className="flex items-center gap-2">
<span className="opacity-60">💨</span>
<WindIcon className="w-4 h-4 opacity-70" />
<span>{weather.wind_speed.toFixed(1)} km/h</span>
</div>
<div className="flex items-center gap-2">
<span className="opacity-60"></span>
<CloudCoverIcon className="w-4 h-4 opacity-70" />
<span>{weather.cloud_cover}%</span>
</div>
{weather.precipitation > 0 && (
<div className="flex items-center gap-2">
<span className="opacity-60">🌧</span>
<RainDropIcon className="w-4 h-4 opacity-70" />
<span>{weather.precipitation} mm</span>
</div>
)}

View File

@@ -1,3 +1,4 @@
import { memo, useEffect, useRef } from 'react'
import type { WidgetConfig } from '../../hooks'
import { widgetRegistry } from './registry'
@@ -5,10 +6,34 @@ interface WidgetContainerProps {
config: WidgetConfig
onRemove?: (id: string) => void
onEdit?: (id: string) => void
onHeightChange?: (height: number) => void
}
export default function WidgetContainer({ config, onRemove, onEdit }: WidgetContainerProps) {
function WidgetContainer({ config, onRemove, onEdit, onHeightChange }: WidgetContainerProps) {
const WidgetComponent = widgetRegistry[config.type]
const contentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!onHeightChange || !contentRef.current) return
const node = contentRef.current
const emitHeight = () => {
onHeightChange(node.scrollHeight)
}
emitHeight()
const resizeObserver = new ResizeObserver(() => {
emitHeight()
})
resizeObserver.observe(node)
return () => {
resizeObserver.disconnect()
}
}, [onHeightChange, config.id])
if (!WidgetComponent) {
return (
@@ -21,10 +46,9 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont
}
return (
<div className="relative group h-full">
{/* Drag handle and actions */}
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-2 opacity-0 group-hover:opacity-100 transition-opacity bg-gradient-to-b from-base-300/90 to-transparent">
<div className="drag-handle cursor-move flex items-center gap-1 px-2 py-1 rounded bg-base-100/80 text-xs">
<div className="relative group h-full w-full">
<div className="absolute top-2 left-2 right-2 z-20 flex items-center justify-between opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<div className="drag-handle cursor-move flex items-center gap-1 px-2 py-1 rounded bg-base-100 shadow-md text-xs border border-base-300 pointer-events-auto">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
@@ -41,11 +65,15 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont
</svg>
Drag
</div>
<div className="flex gap-1">
<div className="flex gap-1 pointer-events-auto">
{onEdit && (
<button
className="btn btn-xs btn-circle btn-ghost bg-base-100/80"
onClick={() => onEdit(config.id)}
type="button"
className="btn btn-xs btn-circle btn-ghost bg-base-100 shadow-md border border-base-300 hover:bg-base-200"
onClick={(e) => {
e.stopPropagation()
onEdit(config.id)
}}
title="Edit widget"
>
<svg
@@ -66,8 +94,12 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont
)}
{onRemove && (
<button
className="btn btn-xs btn-circle btn-ghost bg-base-100/80"
onClick={() => onRemove(config.id)}
type="button"
className="btn btn-xs btn-circle btn-ghost bg-base-100 shadow-md border border-base-300 hover:bg-error hover:text-error-content"
onClick={(e) => {
e.stopPropagation()
onRemove(config.id)
}}
title="Remove widget"
>
<svg
@@ -89,10 +121,12 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont
</div>
</div>
{/* Actual widget */}
<div className="h-full">
{/* Allow overlay to float without reserving layout space */}
<div className="w-full" ref={contentRef}>
<WidgetComponent config={config} />
</div>
</div>
)
}
export default memo(WidgetContainer)

View File

@@ -3,4 +3,5 @@ export { default as LineChartWidget } from './LineChartWidget'
export { default as StatWidget } from './StatWidget'
export { default as GaugeWidget } from './GaugeWidget'
export { default as AiInsightWidget } from './AiInsightWidget'
export { default as CalendarWidget } from './CalendarWidget'
export { widgetRegistry } from './registry'

View File

@@ -8,6 +8,10 @@ import AiInsightWidget from './AiInsightWidget'
import AirQualityWidget from './AirQualityWidget'
import WeatherWidget from './WeatherWidget'
import ComfortIndexWidget from './ComfortIndexWidget'
import RunSuitabilityWidget from './RunSuitabilityWidget'
import HealthStatsWidget from './HealthStatsWidget'
import CalendarWidget from './CalendarWidget'
import DailyBriefingWidget from './DailyBriefingWidget'
interface WidgetProps {
config: WidgetConfig
@@ -22,4 +26,8 @@ export const widgetRegistry: Record<WidgetType, ComponentType<WidgetProps>> = {
'air-quality': AirQualityWidget,
'weather': WeatherWidget,
'comfort-index': ComfortIndexWidget,
'run-suitability': RunSuitabilityWidget,
'health-stats': HealthStatsWidget,
'calendar': CalendarWidget,
'daily-briefing': DailyBriefingWidget,
}

View File

@@ -2,7 +2,7 @@
/* Reduce card body padding for widgets - default is 2rem (32px), we want less */
.widget-card .card-body {
padding: 1rem; /* 16px instead of 32px */
padding: 0.5rem; /* 8px - can be overridden by inline p-2 class */
}
/* Lighter shadow for widgets */
@@ -10,11 +10,12 @@
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
/* Ensure cards fill their container properly */
/* Ensure cards fill their container properly and align to grid */
.widget-card {
height: 100%;
display: flex;
flex-direction: column;
width: 100%;
}
.widget-card .card-body {
@@ -23,3 +24,35 @@
flex-direction: column;
min-height: 0; /* Allow flex children to shrink */
}
/* Gridstack alignment */
.grid-stack-item {
display: flex;
align-items: stretch;
}
.grid-stack-item-content {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Gridstack drag handle */
.grid-stack-item .drag-handle {
cursor: move;
pointer-events: auto !important;
z-index: 10;
}
/* Ensure gridstack items are interactive */
.grid-stack-item {
pointer-events: auto;
}
/* Gridstack resize handles */
.grid-stack-item .ui-resizable-handle {
pointer-events: auto !important;
z-index: 20;
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { dashboardLayoutApi } from '../api'
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather' | 'comfort-index'
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather' | 'comfort-index' | 'run-suitability' | 'health-stats' | 'calendar' | 'daily-briefing'
export interface WidgetConfig {
id: string
@@ -20,6 +21,17 @@ export interface WidgetConfig {
height?: number
city?: string
}
calendar?: {
icalUrl: string
timeRangeHours?: number
}
briefing?: {
briefingType: 'schedule' | 'environment' | 'full'
city: string
healthDeviceId?: string
calendarUrl?: string
calendarRangeHours?: number
}
position?: {
x: number
y: number
@@ -43,30 +55,108 @@ const DEFAULT_CONFIG: DashboardConfig = {
const STORAGE_KEY = 'iot-dashboard-config'
/**
* Hook to manage dashboard configuration with localStorage persistence
* Hook to manage dashboard configuration with backend sync and localStorage fallback
* Single-user system: No authentication required
*/
export function useDashboardConfig() {
const [config, setConfig] = useState<DashboardConfig>(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
return JSON.parse(stored) as DashboardConfig
}
} catch (error) {
console.error('Failed to load dashboard config:', error)
}
return DEFAULT_CONFIG
})
const [config, setConfig] = useState<DashboardConfig>(DEFAULT_CONFIG)
const [isLoading, setIsLoading] = useState(true)
const [layoutId, setLayoutId] = useState<number | null>(null)
const isInitialLoadRef = useRef(true)
// Persist to localStorage whenever config changes
// Load config from backend or localStorage
useEffect(() => {
const loadConfig = async () => {
setIsLoading(true)
try {
// Try to load from backend
try {
const response = await dashboardLayoutApi.getDefault()
const layout = response.data
setConfig(layout.config)
setLayoutId(layout.id)
} catch (error: any) {
// No default layout found, try to create one or use localStorage fallback
console.log('No default layout found, using localStorage or creating new')
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored) as DashboardConfig
setConfig(parsed)
// Save to backend
try {
const response = await dashboardLayoutApi.create({
name: 'default',
config: parsed,
is_default: true,
})
setLayoutId(response.data.id)
} catch (err) {
console.error('Failed to save to backend:', err)
}
}
}
} catch (error) {
console.error('Failed to load dashboard config:', error)
// Fallback to localStorage
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
setConfig(JSON.parse(stored) as DashboardConfig)
}
} catch (e) {
console.error('Failed to load from localStorage:', e)
}
} finally {
setIsLoading(false)
isInitialLoadRef.current = false
}
}
loadConfig()
}, [])
const saveConfig = async () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
} catch (error) {
console.error('Failed to save dashboard config:', error)
console.error('Failed to save to localStorage:', error)
}
// Save to backend
try {
if (layoutId) {
// Update existing layout
await dashboardLayoutApi.update(layoutId, {
config: config,
})
} else {
// Create new layout
const response = await dashboardLayoutApi.create({
name: 'default',
config: config,
is_default: true,
})
setLayoutId(response.data.id)
}
return true
} catch (error) {
console.error('Failed to save to backend:', error)
return false
}
}
// Only save to localStorage automatically (no backend saves)
useEffect(() => {
if (!isInitialLoadRef.current) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
} catch (error) {
console.error('Failed to save to localStorage:', error)
}
}
}, [config])
const addWidget = (widget: WidgetConfig) => {
setConfig((prev) => ({
...prev,
@@ -124,5 +214,7 @@ export function useDashboardConfig() {
resetConfig,
exportConfig,
importConfig,
saveConfig,
isLoading,
}
}

View File

@@ -0,0 +1,155 @@
import { useEffect, useRef, useCallback } from 'react'
import { GridStack } from 'gridstack'
import 'gridstack/dist/gridstack.min.css'
// Define the widget type based on gridstack.js structure
export type GridStackWidget = {
id?: string | number
x?: number
y?: number
w?: number
h?: number
minW?: number
minH?: number
maxW?: number
maxH?: number
noResize?: boolean
noMove?: boolean
locked?: boolean
[key: string]: any // Allow additional properties
}
interface UseGridstackOptions {
columns?: number
cellHeight?: number
margin?: number
minRow?: number
onLayoutChange?: (items: GridStackWidget[]) => void
disableResize?: boolean
disableDrag?: boolean
}
export function useGridstack(options: UseGridstackOptions = {}) {
const gridRef = useRef<HTMLDivElement>(null)
const gridInstanceRef = useRef<GridStack | null>(null)
const {
columns = 4,
cellHeight = 150,
margin = 12,
minRow = 1,
onLayoutChange,
disableResize = false,
disableDrag = false,
} = options
// Initialize gridstack
useEffect(() => {
if (!gridRef.current) return
// Clean up existing instance
if (gridInstanceRef.current) {
gridInstanceRef.current.destroy(false)
}
// Create new gridstack instance
// Gridstack will automatically detect and manage elements with data-gs-* attributes
const grid = GridStack.init(
{
column: columns,
cellHeight,
margin,
minRow,
resizable: {
handles: 'e, se, s, sw, w',
},
disableResize,
disableDrag,
float: false,
animate: true,
acceptWidgets: false,
// Removed handle option - entire widget is draggable for better UX
},
gridRef.current
)
// Handle layout change
if (onLayoutChange) {
grid.on('change', (event, items) => {
const serialized = grid.save(false) as GridStackWidget[]
onLayoutChange(serialized)
})
}
gridInstanceRef.current = grid
return () => {
if (gridInstanceRef.current) {
gridInstanceRef.current.destroy(false)
gridInstanceRef.current = null
}
}
}, [columns, cellHeight, margin, minRow, disableResize, disableDrag, onLayoutChange])
// Convert existing elements to gridstack widgets
const makeWidgets = useCallback(() => {
if (gridInstanceRef.current && gridRef.current) {
const items = gridRef.current.querySelectorAll('.grid-stack-item:not(.ui-draggable)')
items.forEach((item) => {
gridInstanceRef.current!.makeWidget(item as HTMLElement)
})
}
}, [])
// Load items into grid
const loadItems = useCallback((items: GridStackWidget[]) => {
if (gridInstanceRef.current) {
gridInstanceRef.current.load(items)
}
}, [])
// Add item to grid
const addItem = useCallback((item: GridStackWidget) => {
if (gridInstanceRef.current) {
gridInstanceRef.current.addWidget(item)
}
}, [])
// Remove item from grid
const removeItem = useCallback((id: string) => {
if (gridInstanceRef.current) {
const el = gridInstanceRef.current.el.querySelector(`[gs-id="${id}"]`)
if (el) {
gridInstanceRef.current.removeWidget(el as HTMLElement, false)
}
}
}, [])
// Update item
const updateItem = useCallback((id: string, updates: Partial<GridStackWidget>) => {
if (gridInstanceRef.current) {
const el = gridInstanceRef.current.el.querySelector(`[gs-id="${id}"]`)
if (el) {
gridInstanceRef.current.update(el as HTMLElement, updates)
}
}
}, [])
// Get current layout
const getLayout = useCallback((): GridStackWidget[] => {
if (gridInstanceRef.current) {
return gridInstanceRef.current.save(false) as GridStackWidget[]
}
return []
}, [])
return {
gridRef,
makeWidgets,
loadItems,
addItem,
removeItem,
updateItem,
getLayout,
}
}

View File

@@ -62,7 +62,8 @@ export function useTelemetrySeries({
return response.data as Telemetry[]
},
enabled,
refetchInterval: 30000, // Refresh every 30 seconds
refetchInterval: enabled ? 60000 : false, // Refresh every 60 seconds (reduced frequency)
staleTime: 30000, // Consider data fresh for 30 seconds
})
return {

View File

@@ -0,0 +1,40 @@
import { createContext, useContext, useState, ReactNode } from 'react'
interface WellnessState {
healthDeviceId: string | null
city: string
setHealthDeviceId: (id: string | null) => void
setCity: (city: string) => void
}
const WellnessStateContext = createContext<WellnessState | undefined>(undefined)
export function WellnessStateProvider({ children }: { children: ReactNode }) {
const [healthDeviceId, setHealthDeviceId] = useState<string | null>(null)
const [city, setCity] = useState<string>('Skopje')
return (
<WellnessStateContext.Provider
value={{
healthDeviceId,
city,
setHealthDeviceId,
setCity,
}}
>
{children}
</WellnessStateContext.Provider>
)
}
export function useWellnessState() {
const context = useContext(WellnessStateContext)
if (context === undefined) {
throw new Error('useWellnessState must be used within WellnessStateProvider')
}
return context
}

View File

@@ -8,27 +8,13 @@ export const apiClient = axios.create({
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // For session auth
});
// Add request interceptor for JWT token (if using JWT)
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Add response interceptor for error handling
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized - redirect to login
localStorage.removeItem('access_token');
// window.location.href = '/login';
}
// Basic error handling - can be extended if needed
return Promise.reject(error);
}
);

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import GridLayout from 'react-grid-layout'
import 'react-grid-layout/css/styles.css'
import { useDashboardConfig } from '../hooks'
@@ -6,18 +6,35 @@ import { WidgetContainer } from '../components/widgets'
import AddWidgetModal from '../components/AddWidgetModal'
import EditWidgetModal from '../components/EditWidgetModal'
const GRID_COLUMNS = 5
const GRID_MARGIN: [number, number] = [8, 6]
const ROW_HEIGHT = 90
const HEIGHT_PADDING = 0
const ROW_UNIT = ROW_HEIGHT + GRID_MARGIN[1]
const MAX_AUTO_ROWS = 6
export default function Dashboard() {
const { config, addWidget, removeWidget, updateWidget, exportConfig, importConfig } = useDashboardConfig()
const { config, addWidget, removeWidget, updateWidget, exportConfig, importConfig, saveConfig } = useDashboardConfig()
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingWidget, setEditingWidget] = useState<string | null>(null)
const [gridWidth, setGridWidth] = useState(1200)
const [isSaving, setIsSaving] = useState(false)
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle')
const [gridWidth, setGridWidth] = useState(() => {
if (typeof window !== 'undefined') {
return window.innerWidth
}
return GRID_COLUMNS * (ROW_HEIGHT + GRID_MARGIN[0])
})
const gridContainerRef = useRef<HTMLDivElement>(null)
// Update grid width on resize
useEffect(() => {
const updateWidth = () => {
if (gridContainerRef.current) {
setGridWidth(gridContainerRef.current.offsetWidth)
const rect = gridContainerRef.current.getBoundingClientRect()
setGridWidth(rect.width)
} else if (typeof window !== 'undefined') {
setGridWidth(window.innerWidth)
}
}
@@ -43,16 +60,44 @@ export default function Dashboard() {
})
}
const layout = config.widgets.map((widget) => ({
i: widget.id,
x: widget.position?.x || 0,
y: widget.position?.y || 0,
w: widget.position?.w || 1,
h: widget.position?.h || 2,
minW: 1,
minH: 1,
maxW: 4,
}))
const layout = config.widgets.map((widget) => {
const position = widget.position ?? { x: 0, y: Infinity, w: 1, h: 1 }
return {
i: widget.id,
x: position.x ?? 0,
y: position.y ?? Infinity,
w: Math.max(position.w ?? 1, 1),
h: Math.max(position.h ?? 1, 1),
minW: 1,
minH: 1,
maxW: GRID_COLUMNS,
}
})
const handleWidgetHeightChange = useCallback(
(widgetId: string, contentHeight: number) => {
const widget = config.widgets.find((w) => w.id === widgetId)
if (!widget) return
const position = widget.position ?? { x: 0, y: Infinity, w: 1, h: 1 }
const currentRows = Math.max(position.h ?? 1, 1)
const desiredPixelHeight = contentHeight + HEIGHT_PADDING
const targetRows = Math.min(
MAX_AUTO_ROWS,
Math.max(1, Math.ceil(desiredPixelHeight / ROW_UNIT))
)
if (Math.abs(targetRows - currentRows) >= 1) {
updateWidget(widgetId, {
position: {
...position,
h: targetRows,
},
})
}
},
[config.widgets, updateWidget]
)
const handleExport = () => {
const json = exportConfig()
@@ -83,6 +128,21 @@ export default function Dashboard() {
reader.readAsText(file)
}
const handleSaveDashboard = async () => {
setIsSaving(true)
setSaveStatus('idle')
try {
const success = await saveConfig()
setSaveStatus(success ? 'success' : 'error')
} catch (error) {
console.error('Failed to save dashboard configuration:', error)
setSaveStatus('error')
} finally {
setIsSaving(false)
setTimeout(() => setSaveStatus('idle'), 3000)
}
}
return (
<div className="p-6 space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
@@ -92,7 +152,30 @@ export default function Dashboard() {
Customize your view with modular widgets
</p>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap items-center gap-2">
<button
className="btn btn-success btn-sm"
onClick={handleSaveDashboard}
disabled={isSaving}
>
{isSaving ? (
<svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
Save Dashboard
</button>
{saveStatus === 'success' && (
<span className="text-success text-sm">Saved!</span>
)}
{saveStatus === 'error' && (
<span className="text-error text-sm">Save failed</span>
)}
<button
className="btn btn-outline btn-sm"
onClick={handleExport}
@@ -148,32 +231,34 @@ export default function Dashboard() {
</div>
</div>
) : (
<div className="w-full" ref={gridContainerRef}>
<GridLayout
className="layout"
layout={layout}
cols={4}
rowHeight={150}
width={gridWidth}
onLayoutChange={handleLayoutChange}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
isResizable={true}
isDraggable={true}
margin={[12, 12]}
>
{config.widgets.map((widget) => (
<div key={widget.id} className="h-full">
<WidgetContainer
config={widget}
onRemove={() => removeWidget(widget.id)}
onEdit={() => setEditingWidget(widget.id)}
/>
</div>
))}
</GridLayout>
</div>
<div className="w-full" ref={gridContainerRef}>
<GridLayout
className="layout"
layout={layout}
cols={GRID_COLUMNS}
rowHeight={ROW_HEIGHT}
width={gridWidth}
onLayoutChange={handleLayoutChange}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
isResizable={true}
isDraggable={true}
margin={GRID_MARGIN}
containerPadding={[0, 0]}
>
{config.widgets.map((widget) => (
<div key={widget.id} className="h-full">
<WidgetContainer
config={widget}
onRemove={() => removeWidget(widget.id)}
onEdit={() => setEditingWidget(widget.id)}
onHeightChange={(height: number) => handleWidgetHeightChange(widget.id, height)}
/>
</div>
))}
</GridLayout>
</div>
)}
<AddWidgetModal