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

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