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