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

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