mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 17:16:26 +00:00
Added Calendar viewer and Environment overview with AI, Health overviewer
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
155
frontend/src/hooks/useGridstack.ts
Normal file
155
frontend/src/hooks/useGridstack.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { GridStack } from 'gridstack'
|
||||
import 'gridstack/dist/gridstack.min.css'
|
||||
|
||||
// Define the widget type based on gridstack.js structure
|
||||
export type GridStackWidget = {
|
||||
id?: string | number
|
||||
x?: number
|
||||
y?: number
|
||||
w?: number
|
||||
h?: number
|
||||
minW?: number
|
||||
minH?: number
|
||||
maxW?: number
|
||||
maxH?: number
|
||||
noResize?: boolean
|
||||
noMove?: boolean
|
||||
locked?: boolean
|
||||
[key: string]: any // Allow additional properties
|
||||
}
|
||||
|
||||
interface UseGridstackOptions {
|
||||
columns?: number
|
||||
cellHeight?: number
|
||||
margin?: number
|
||||
minRow?: number
|
||||
onLayoutChange?: (items: GridStackWidget[]) => void
|
||||
disableResize?: boolean
|
||||
disableDrag?: boolean
|
||||
}
|
||||
|
||||
export function useGridstack(options: UseGridstackOptions = {}) {
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const gridInstanceRef = useRef<GridStack | null>(null)
|
||||
const {
|
||||
columns = 4,
|
||||
cellHeight = 150,
|
||||
margin = 12,
|
||||
minRow = 1,
|
||||
onLayoutChange,
|
||||
disableResize = false,
|
||||
disableDrag = false,
|
||||
} = options
|
||||
|
||||
// Initialize gridstack
|
||||
useEffect(() => {
|
||||
if (!gridRef.current) return
|
||||
|
||||
// Clean up existing instance
|
||||
if (gridInstanceRef.current) {
|
||||
gridInstanceRef.current.destroy(false)
|
||||
}
|
||||
|
||||
// Create new gridstack instance
|
||||
// Gridstack will automatically detect and manage elements with data-gs-* attributes
|
||||
const grid = GridStack.init(
|
||||
{
|
||||
column: columns,
|
||||
cellHeight,
|
||||
margin,
|
||||
minRow,
|
||||
resizable: {
|
||||
handles: 'e, se, s, sw, w',
|
||||
},
|
||||
disableResize,
|
||||
disableDrag,
|
||||
float: false,
|
||||
animate: true,
|
||||
acceptWidgets: false,
|
||||
// Removed handle option - entire widget is draggable for better UX
|
||||
},
|
||||
gridRef.current
|
||||
)
|
||||
|
||||
// Handle layout change
|
||||
if (onLayoutChange) {
|
||||
grid.on('change', (event, items) => {
|
||||
const serialized = grid.save(false) as GridStackWidget[]
|
||||
onLayoutChange(serialized)
|
||||
})
|
||||
}
|
||||
|
||||
gridInstanceRef.current = grid
|
||||
|
||||
return () => {
|
||||
if (gridInstanceRef.current) {
|
||||
gridInstanceRef.current.destroy(false)
|
||||
gridInstanceRef.current = null
|
||||
}
|
||||
}
|
||||
}, [columns, cellHeight, margin, minRow, disableResize, disableDrag, onLayoutChange])
|
||||
|
||||
// Convert existing elements to gridstack widgets
|
||||
const makeWidgets = useCallback(() => {
|
||||
if (gridInstanceRef.current && gridRef.current) {
|
||||
const items = gridRef.current.querySelectorAll('.grid-stack-item:not(.ui-draggable)')
|
||||
items.forEach((item) => {
|
||||
gridInstanceRef.current!.makeWidget(item as HTMLElement)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load items into grid
|
||||
const loadItems = useCallback((items: GridStackWidget[]) => {
|
||||
if (gridInstanceRef.current) {
|
||||
gridInstanceRef.current.load(items)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Add item to grid
|
||||
const addItem = useCallback((item: GridStackWidget) => {
|
||||
if (gridInstanceRef.current) {
|
||||
gridInstanceRef.current.addWidget(item)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Remove item from grid
|
||||
const removeItem = useCallback((id: string) => {
|
||||
if (gridInstanceRef.current) {
|
||||
const el = gridInstanceRef.current.el.querySelector(`[gs-id="${id}"]`)
|
||||
if (el) {
|
||||
gridInstanceRef.current.removeWidget(el as HTMLElement, false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update item
|
||||
const updateItem = useCallback((id: string, updates: Partial<GridStackWidget>) => {
|
||||
if (gridInstanceRef.current) {
|
||||
const el = gridInstanceRef.current.el.querySelector(`[gs-id="${id}"]`)
|
||||
if (el) {
|
||||
gridInstanceRef.current.update(el as HTMLElement, updates)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Get current layout
|
||||
const getLayout = useCallback((): GridStackWidget[] => {
|
||||
if (gridInstanceRef.current) {
|
||||
return gridInstanceRef.current.save(false) as GridStackWidget[]
|
||||
}
|
||||
return []
|
||||
}, [])
|
||||
|
||||
return {
|
||||
gridRef,
|
||||
makeWidgets,
|
||||
loadItems,
|
||||
addItem,
|
||||
removeItem,
|
||||
updateItem,
|
||||
getLayout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ export function useTelemetrySeries({
|
||||
return response.data as Telemetry[]
|
||||
},
|
||||
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 {
|
||||
|
||||
40
frontend/src/hooks/useWellnessState.tsx
Normal file
40
frontend/src/hooks/useWellnessState.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createContext, useContext, useState, ReactNode } from 'react'
|
||||
|
||||
interface WellnessState {
|
||||
healthDeviceId: string | null
|
||||
city: string
|
||||
setHealthDeviceId: (id: string | null) => void
|
||||
setCity: (city: string) => void
|
||||
}
|
||||
|
||||
const WellnessStateContext = createContext<WellnessState | undefined>(undefined)
|
||||
|
||||
export function WellnessStateProvider({ children }: { children: ReactNode }) {
|
||||
const [healthDeviceId, setHealthDeviceId] = useState<string | null>(null)
|
||||
const [city, setCity] = useState<string>('Skopje')
|
||||
|
||||
return (
|
||||
<WellnessStateContext.Provider
|
||||
value={{
|
||||
healthDeviceId,
|
||||
city,
|
||||
setHealthDeviceId,
|
||||
setCity,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</WellnessStateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWellnessState() {
|
||||
const context = useContext(WellnessStateContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useWellnessState must be used within WellnessStateProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user