Introduced air quality and weather, onboarding for mobile devices with qr code and otp. Cascade on delete of device with telemtry.

This commit is contained in:
2025-11-14 00:45:10 +01:00
parent ed105fccd3
commit 7f2aec96dd
34 changed files with 1186 additions and 426 deletions

8
frontend/.env.example Normal file
View File

@@ -0,0 +1,8 @@
# API Configuration
VITE_API_URL=/api
# Django proxy URL (used for QR code API endpoint)
VITE_DEVICE_MANAGER_URL=http://10.10.1.114:3000/api
# MQTT Configuration
VITE_MQTT_BROKER=10.10.1.114
VITE_MQTT_PORT=8883

1
frontend/.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*

View File

@@ -13,11 +13,11 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-toast": "^1.2.15",
"@tanstack/react-query": "^5.62.8",
"@types/react-grid-layout": "^1.3.5",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.462.0",
"qrcode.react": "^4.2.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-grid-layout": "^1.5.2",
@@ -2520,6 +2520,7 @@
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -2535,15 +2536,6 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-grid-layout": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz",
"integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==",
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.46.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz",
@@ -4921,6 +4913,15 @@
"node": ">=6"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@@ -10,42 +10,19 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-toast": "^1.2.15",
"@tanstack/react-query": "^5.62.8",
"@types/react-grid-layout": "^1.3.5",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.462.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-grid-layout": "^1.5.2",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.6.0",
"react-router": "^7.1.0",
"react-router-dom": "^7.9.5",
"recharts": "^2.15.0",
"tailwind-merge": "^2.5.5",
"zod": "^3.24.1",
"zustand": "^5.0.2"
"react-dom": "^19.1.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"autoprefixer": "^10.4.20",
"daisyui": "^5.4.2",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"tailwindcss": "^4.0.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -17,7 +17,24 @@ interface PaginatedResponse<T> {
// Device API
export const devicesApi = {
getAll: () => apiClient.get<PaginatedResponse<Device>>('/devices/'),
getAll: async () => {
const response = await apiClient.get<Device[] | PaginatedResponse<Device>>('/devices/');
// Handle both paginated and non-paginated responses
if (Array.isArray(response.data)) {
// Non-paginated response - wrap it
return {
...response,
data: {
count: response.data.length,
next: null,
previous: null,
results: response.data,
},
};
}
// Already paginated
return response as typeof response & { data: PaginatedResponse<Device> };
},
getOne: (id: string) => apiClient.get<Device>(`/devices/${id}/`),
@@ -78,3 +95,37 @@ export const telemetryApi = {
export const dashboardApi = {
getOverview: () => apiClient.get<DashboardOverview>('/dashboard/overview/'),
};
// Weather API
export const weatherApi = {
getCurrent: (params: { city?: string; lat?: number; lon?: number }) =>
apiClient.get<{
location: string;
temperature: number;
apparent_temperature: number;
humidity: number;
weather_description: string;
weather_code: number;
precipitation: number;
rain: number;
cloud_cover: number;
wind_speed: number;
wind_direction: number;
time: string;
timezone: string;
}>('/weather/current/', { params }),
getAirQuality: (city: string) =>
apiClient.get<{
city: string;
measurements: Record<string, {
average: number;
min: number;
max: number;
count: number;
}>;
status: string;
timestamp: string;
sensor_count: number;
}>('/weather/air_quality/', { params: { city } }),
};

View File

@@ -17,8 +17,9 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
const [timeframeHours, setTimeframeHours] = useState(24)
const [widgetWidth, setWidgetWidth] = useState(1) // Default to 1 column (small)
const [widgetHeight, setWidgetHeight] = useState(2) // Default to 2 rows (medium)
const [widgetWidth, setWidgetWidth] = useState(1)
const [widgetHeight, setWidgetHeight] = useState(2)
const [city, setCity] = useState('Skopje')
// Fetch devices
const { data: devicesData } = useQuery({
@@ -63,20 +64,31 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
}, [selectedDevices])
const handleSubmit = () => {
if (selectedDevices.length === 0 || selectedMetrics.length === 0) {
alert('Please select at least one device and one metric')
return
// 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
}
}
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
const defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics)
// Create title
let defaultTitle = ''
if (widgetType === 'weather') {
defaultTitle = `Weather - ${city}`
} else if (widgetType === 'air-quality') {
defaultTitle = `Air Quality - ${city}`
} else {
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics)
}
const newWidget: WidgetConfig = {
id: `widget-${Date.now()}`,
type: widgetType,
title: title || defaultTitle,
deviceIds: selectedDevices,
metricIds: selectedMetrics,
deviceIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedDevices,
metricIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedMetrics,
timeframe: {
hours: timeframeHours,
},
@@ -84,6 +96,7 @@ 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,
},
position: {
x: 0,
@@ -197,6 +210,32 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
<div className="text-xs opacity-70">GPT analysis</div>
</div>
</button>
<button
className={`btn ${widgetType === 'weather' ? 'btn-primary' : 'btn-outline'} justify-start`}
onClick={() => setWidgetType('weather')}
>
<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="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
<div className="text-left">
<div className="font-semibold">Weather</div>
<div className="text-xs opacity-70">Open-Meteo</div>
</div>
</button>
<button
className={`btn ${widgetType === 'air-quality' ? 'btn-primary' : 'btn-outline'} justify-start`}
onClick={() => setWidgetType('air-quality')}
>
<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="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
<div className="text-left">
<div className="font-semibold">Air Quality</div>
<div className="text-xs opacity-70">Pulse.eco</div>
</div>
</button>
</div>
</div>
@@ -204,7 +243,17 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
<button className="btn btn-ghost" onClick={onClose}>
Cancel
</button>
<button className="btn btn-primary" onClick={() => setStep(2)}>
<button
className="btn btn-primary"
onClick={() => {
// Skip data source step for weather and air quality widgets
if (widgetType === 'weather' || widgetType === 'air-quality') {
setStep(3)
} else {
setStep(2)
}
}}
>
Next
</button>
</div>
@@ -212,7 +261,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
)}
{/* Step 2: Data Source */}
{step === 2 && (
{step === 2 && widgetType !== 'weather' && widgetType !== 'air-quality' && (
<div className="space-y-4">
<div className="form-control">
<label className="label">
@@ -304,35 +353,76 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
{/* Step 3: Configure */}
{step === 3 && (
<div className="space-y-4">
<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="Auto-generated if empty"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
{/* City input for weather and air-quality widgets */}
{(widgetType === 'weather' || widgetType === 'air-quality') ? (
<>
<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)}
/>
<label className="label">
<span className="label-text-alt">
{widgetType === 'air-quality'
? 'Available cities: Skopje, Bitola, Veles, Tetovo, etc.'
: 'Enter any city name for weather data'}
</span>
</label>
</div>
<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>
<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={widgetType === 'weather' ? `Weather - ${city}` : `Air Quality - ${city}`}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
</>
) : (
// Original configuration for sensor-based widgets
<>
<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="Auto-generated if empty"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<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>
</>
)}
<div className="grid grid-cols-2 gap-4">
<div className="form-control">

View File

@@ -1,3 +1,5 @@
import { useState } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import toast from 'react-hot-toast'
import type { DeviceRegistrationResponse } from '../types/api'
@@ -27,9 +29,63 @@ const copyToClipboard = (content: string, label: string) => {
export default function CredentialsViewer({ credentials, deviceId }: CredentialsViewerProps) {
const resolvedDeviceId = credentials.device_id || deviceId || 'device'
const expiresAt = credentials.expires_at ? new Date(credentials.expires_at).toLocaleString() : null
const [showQR, setShowQR] = useState(false)
// Read configuration from environment variables
const deviceManagerUrl = import.meta.env.VITE_DEVICE_MANAGER_URL || 'http://localhost:8000'
const mqttBroker = import.meta.env.VITE_MQTT_BROKER || 'localhost'
const mqttPort = import.meta.env.VITE_MQTT_PORT || '8883'
const qrData = credentials.onboarding_token ? JSON.stringify({
type: 'iot_device_onboarding',
device_id: resolvedDeviceId,
token: credentials.onboarding_token,
api_url: deviceManagerUrl,
broker: mqttBroker,
port: parseInt(mqttPort, 10),
}) : null
return (
<div className="space-y-4">
{/* Secure QR Code for Mobile Onboarding */}
{qrData && (
<div className="rounded-lg bg-success/10 border border-success/30 p-4">
<div className="flex items-start gap-3">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-success shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<div className="flex-1">
<h3 className="font-semibold mb-1">Secure Mobile Onboarding</h3>
<p className="text-sm opacity-80 mb-2">
Scan this QR code with your mobile app to securely fetch certificates. Token expires in <strong>15 minutes</strong> and can only be used <strong>once</strong>.
</p>
<div className="alert alert-warning alert-sm mb-3">
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24">
<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-xs">This QR code will not be shown again. Scan it now!</span>
</div>
<button
className="btn btn-sm btn-success"
onClick={() => setShowQR(!showQR)}
>
{showQR ? 'Hide QR Code' : 'Show QR Code'}
</button>
{showQR && (
<div className="mt-4 flex justify-center p-6 bg-white rounded-lg border-2 border-success">
<QRCodeSVG
value={qrData}
size={280}
level="H"
includeMargin={true}
/>
</div>
)}
</div>
</div>
</div>
)}
{(credentials.certificate_id || expiresAt) && (
<div className="rounded-lg bg-base-200 p-4 text-sm">
<div className="flex flex-col gap-2">

View File

@@ -0,0 +1,138 @@
import { useQuery } from '@tanstack/react-query'
import type { WidgetConfig } from '../../hooks'
import { weatherApi } from '../../api'
interface AirQualityWidgetProps {
config: WidgetConfig
}
export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
// Get city from config or use default (Pulse.eco city)
const city = (config.visualization as Record<string, unknown>)?.city as string || 'skopje'
const { data: airQuality, isLoading, error } = useQuery({
queryKey: ['air-quality', city],
queryFn: async () => {
const response = await weatherApi.getAirQuality(city)
return response.data
},
refetchInterval: 300000, // Refresh every 5 minutes
staleTime: 240000, // Consider fresh for 4 minutes
})
if (isLoading) {
return (
<div className="card bg-base-100 shadow-lg 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="card bg-base-100 shadow-lg h-full">
<div className="card-body">
<h2 className="card-title text-sm">{config.title}</h2>
<div className="flex flex-col items-center justify-center flex-1">
<p className="text-error text-sm text-center">
Failed to load air quality data for {city}
</p>
<p className="text-xs text-base-content/60 mt-2">
Try: skopje, bitola, tetovo
</p>
</div>
</div>
</div>
)
}
if (!airQuality) return null
// Get AQI color based on status
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case 'good':
return 'success'
case 'moderate':
return 'warning'
case 'unhealthy for sensitive groups':
case 'unhealthy':
return 'error'
case 'very unhealthy':
case 'hazardous':
return 'error'
default:
return 'base-content/40'
}
}
const statusColor = getStatusColor(airQuality.status)
const pm10 = airQuality.measurements.pm10
const pm25 = airQuality.measurements.pm25
return (
<div className="card bg-base-100 shadow-lg h-full">
<div className="card-body">
<h2 className="card-title text-sm">{config.title}</h2>
<div className="flex flex-col items-center justify-center flex-1">
{/* Air quality icon */}
<svg
xmlns="http://www.w3.org/2000/svg"
className={`h-16 w-16 text-${statusColor} mb-2`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
/>
</svg>
{/* PM Values */}
<div className="grid grid-cols-2 gap-4 w-full mb-3">
{pm10 && (
<div className="text-center">
<div className="text-2xl font-bold">{pm10.average.toFixed(1)}</div>
<div className="text-xs text-base-content/60">PM10 μg/m³</div>
</div>
)}
{pm25 && (
<div className="text-center">
<div className="text-2xl font-bold">{pm25.average.toFixed(1)}</div>
<div className="text-xs text-base-content/60">PM2.5 μg/m³</div>
</div>
)}
</div>
{/* AQI Status badge */}
<div className={`badge badge-${statusColor} badge-lg`}>
{airQuality.status}
</div>
{/* Additional pollutants */}
<div className="grid grid-cols-2 gap-2 mt-3 w-full text-xs">
{Object.entries(airQuality.measurements).map(([pollutant, data]) => {
if (pollutant === 'pm10' || pollutant === 'pm25') return null
return (
<div key={pollutant} className="flex justify-between">
<span className="opacity-60">{pollutant.toUpperCase()}:</span>
<span className="font-semibold">{data.average.toFixed(1)}</span>
</div>
)
})}
</div>
{/* City and sensor count */}
<div className="text-xs text-base-content/40 mt-3">
{airQuality.city.charAt(0).toUpperCase() + airQuality.city.slice(1)} {airQuality.sensor_count} sensors
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,110 @@
import { useQuery } from '@tanstack/react-query'
import type { WidgetConfig } from '../../hooks'
import { weatherApi } from '../../api'
interface WeatherWidgetProps {
config: WidgetConfig
}
export default function WeatherWidget({ config }: WeatherWidgetProps) {
// Get city from config or use default
const city = (config.visualization as Record<string, unknown>)?.city as string || 'Skopje'
const { data: weather, isLoading, error } = useQuery({
queryKey: ['weather', city],
queryFn: async () => {
const response = await weatherApi.getCurrent({ city })
return response.data
},
refetchInterval: 300000, // Refresh every 5 minutes
staleTime: 240000, // Consider fresh for 4 minutes
})
if (isLoading) {
return (
<div className="card bg-base-100 shadow-lg 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="card bg-base-100 shadow-lg h-full">
<div className="card-body">
<h2 className="card-title text-sm">{config.title}</h2>
<div className="flex flex-col items-center justify-center flex-1">
<p className="text-error">Failed to load weather data</p>
</div>
</div>
</div>
)
}
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 '🌡️'
}
return (
<div className="card bg-base-100 shadow-lg h-full">
<div className="card-body">
<h2 className="card-title text-sm">{config.title}</h2>
<div className="flex flex-col items-center justify-center flex-1">
{/* Weather Icon */}
<div className="text-6xl mb-2">{getWeatherIcon(weather.weather_code)}</div>
{/* Temperature */}
<div className="text-4xl font-bold">{weather.temperature.toFixed(1)}°C</div>
<div className="text-sm text-base-content/60">
Feels like {weather.apparent_temperature.toFixed(1)}°C
</div>
{/* Weather Description */}
<div className="badge badge-primary badge-lg mt-2">
{weather.weather_description}
</div>
{/* Additional Info */}
<div className="grid grid-cols-2 gap-4 mt-4 w-full text-sm">
<div className="flex items-center gap-2">
<span className="opacity-60">💧</span>
<span>{weather.humidity}%</span>
</div>
<div className="flex items-center gap-2">
<span className="opacity-60">💨</span>
<span>{weather.wind_speed.toFixed(1)} km/h</span>
</div>
<div className="flex items-center gap-2">
<span className="opacity-60"></span>
<span>{weather.cloud_cover}%</span>
</div>
{weather.precipitation > 0 && (
<div className="flex items-center gap-2">
<span className="opacity-60">🌧</span>
<span>{weather.precipitation} mm</span>
</div>
)}
</div>
{/* Location */}
<div className="text-xs text-base-content/40 mt-3">{weather.location}</div>
</div>
</div>
</div>
)
}

View File

@@ -5,6 +5,8 @@ import LineChartWidget from './LineChartWidget'
import StatWidget from './StatWidget'
import GaugeWidget from './GaugeWidget'
import AiInsightWidget from './AiInsightWidget'
import AirQualityWidget from './AirQualityWidget'
import WeatherWidget from './WeatherWidget'
interface WidgetProps {
config: WidgetConfig
@@ -16,4 +18,6 @@ export const widgetRegistry: Record<WidgetType, ComponentType<WidgetProps>> = {
'gauge': GaugeWidget,
'ai-insight': AiInsightWidget,
'bar-chart': LineChartWidget, // Placeholder - implement later
'air-quality': AirQualityWidget,
'weather': WeatherWidget,
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart'
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather'
export interface WidgetConfig {
id: string
@@ -18,6 +18,7 @@ export interface WidgetConfig {
showLegend?: boolean
showGrid?: boolean
height?: number
city?: string
}
position?: {
x: number

View File

@@ -48,6 +48,7 @@ export interface DeviceRegistrationResponse {
certificate_pem?: string;
private_key_pem?: string;
expires_at?: string;
onboarding_token?: string; // One-time token for secure onboarding (valid 15 min)
}
export interface DashboardOverview {