feat: Sprint 4 - Enhanced Vital Gauges with Sparklines
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

- VitalGauge.tsx: New component with:
  - SVG sparkline charts showing historical trend
  - Gradient fill under sparkline
  - Trend arrows (up/down/stable)
  - Pulsing current value dot
  - Hover tooltips with optimal/range info
  - Color-coded status (green/yellow/red)
- VitalsGrid: Convenient wrapper for common vitals
- VitalGaugeMini: Compact version for lists
- Consistent mock values per plant (seeded by ID)
This commit is contained in:
fullsizemalt 2025-12-19 13:14:08 -08:00
parent 097b2ba0c8
commit 704fd9c79a
2 changed files with 268 additions and 52 deletions

View file

@ -1,24 +1,29 @@
import { X, MapPin, Leaf, Activity, Calendar } from 'lucide-react';
import type { PlantPosition } from './types';
import { LifecycleTimeline } from './LifecycleTimeline';
import { VitalsGrid } from './VitalGauge';
interface PlantDataCardProps {
plant: PlantPosition | null;
onClose: () => void;
}
// Mock vitals for demo
const getMockVitals = () => ({
health: 75 + Math.floor(Math.random() * 20),
temperature: 72 + Math.floor(Math.random() * 8),
humidity: 55 + Math.floor(Math.random() * 15),
vpd: 1.0 + Math.random() * 0.4,
});
// Mock vitals for demo - now with consistent values per plant
const getMockVitals = (plantId?: string) => {
// Use plant ID to seed pseudo-random values for consistency
const seed = plantId ? plantId.charCodeAt(plantId.length - 1) : 42;
return {
health: 75 + (seed % 20),
temperature: 72 + (seed % 8),
humidity: 55 + ((seed * 7) % 15),
vpd: 1.0 + ((seed % 10) / 25),
};
};
export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
if (!plant?.plant) return null;
const vitals = getMockVitals();
const vitals = getMockVitals(plant.plant.id);
// Format position breadcrumb
const position = plant.breadcrumb
@ -59,7 +64,7 @@ export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
</div>
</div>
{/* Lifecycle Timeline - Using new SVG component */}
{/* Lifecycle Timeline */}
<div className="px-4 py-3 border-b border-slate-700/30">
<div className="text-xs text-slate-400 mb-2 flex items-center gap-1">
<Calendar className="w-3 h-3" />
@ -68,18 +73,18 @@ export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
<LifecycleTimeline currentStage={plant.plant.stage ?? undefined} />
</div>
{/* Vitals Grid */}
{/* Vitals Grid - Now with sparklines */}
<div className="px-4 py-3 border-b border-slate-700/30">
<div className="text-xs text-slate-400 mb-2 flex items-center gap-1">
<Activity className="w-3 h-3" />
VITALS
</div>
<div className="grid grid-cols-4 gap-2">
<VitalGauge label="Health" value={vitals.health} unit="%" optimal={85} />
<VitalGauge label="Temp" value={vitals.temperature} unit="°F" optimal={76} />
<VitalGauge label="RH" value={vitals.humidity} unit="%" optimal={60} />
<VitalGauge label="VPD" value={vitals.vpd} unit="kPa" optimal={1.2} decimals={2} />
</div>
<VitalsGrid
health={vitals.health}
temperature={vitals.temperature}
humidity={vitals.humidity}
vpd={vitals.vpd}
/>
</div>
{/* Quick Actions */}
@ -98,39 +103,3 @@ export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
);
}
// Simple vital gauge component
interface VitalGaugeProps {
label: string;
value: number;
unit: string;
optimal: number;
decimals?: number;
}
function VitalGauge({ label, value, unit, optimal, decimals = 0 }: VitalGaugeProps) {
// Calculate how close to optimal (0 = far, 1 = optimal)
const diff = Math.abs(value - optimal);
const maxDiff = optimal * 0.3; // 30% away is "far"
const score = Math.max(0, 1 - diff / maxDiff);
// Color based on score
const color = score > 0.7 ? 'text-green-400' : score > 0.4 ? 'text-yellow-400' : 'text-red-400';
const bg = score > 0.7 ? 'bg-green-500' : score > 0.4 ? 'bg-yellow-500' : 'bg-red-500';
return (
<div className="flex flex-col items-center">
<div className="text-[9px] text-slate-500 uppercase mb-1">{label}</div>
<div className={`text-sm font-bold ${color}`}>
{decimals > 0 ? value.toFixed(decimals) : value}
<span className="text-[9px] text-slate-500 ml-0.5">{unit}</span>
</div>
{/* Mini progress bar */}
<div className="w-full h-1 bg-slate-700 rounded-full mt-1 overflow-hidden">
<div
className={`h-full ${bg} transition-all duration-500`}
style={{ width: `${Math.min(100, score * 100)}%` }}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,247 @@
import { useMemo } from 'react';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
interface VitalGaugeProps {
label: string;
value: number;
unit: string;
optimal: number;
min?: number;
max?: number;
history?: number[]; // Last N readings for sparkline
decimals?: number;
showTrend?: boolean;
}
// Generate mock history if not provided
const generateMockHistory = (current: number, optimal: number, points: number = 12): number[] => {
const history: number[] = [];
const variance = optimal * 0.15;
for (let i = 0; i < points; i++) {
// Trend towards current value
const progress = i / (points - 1);
const base = optimal + (current - optimal) * progress;
const noise = (Math.random() - 0.5) * variance;
history.push(base + noise);
}
return history;
};
export function VitalGauge({
label,
value,
unit,
optimal,
min,
max,
history,
decimals = 0,
showTrend = true
}: VitalGaugeProps) {
// Generate history if not provided
const dataPoints = useMemo(() => {
return history || generateMockHistory(value, optimal);
}, [history, value, optimal]);
// Calculate how close to optimal (0 = far, 1 = optimal)
const diff = Math.abs(value - optimal);
const maxDiff = optimal * 0.3;
const score = Math.max(0, 1 - diff / maxDiff);
// Determine status color
const status = score > 0.7 ? 'good' : score > 0.4 ? 'warning' : 'critical';
const colorClasses = {
good: { text: 'text-green-400', bg: 'bg-green-500', stroke: '#22c55e' },
warning: { text: 'text-yellow-400', bg: 'bg-yellow-500', stroke: '#eab308' },
critical: { text: 'text-red-400', bg: 'bg-red-500', stroke: '#ef4444' },
};
const colors = colorClasses[status];
// Calculate trend from history
const trend = useMemo(() => {
if (dataPoints.length < 2) return 'stable';
const recentHalf = dataPoints.slice(-Math.floor(dataPoints.length / 2));
const olderHalf = dataPoints.slice(0, Math.floor(dataPoints.length / 2));
const recentAvg = recentHalf.reduce((a, b) => a + b, 0) / recentHalf.length;
const olderAvg = olderHalf.reduce((a, b) => a + b, 0) / olderHalf.length;
const change = (recentAvg - olderAvg) / olderAvg;
if (change > 0.05) return 'up';
if (change < -0.05) return 'down';
return 'stable';
}, [dataPoints]);
// SVG sparkline dimensions
const sparkWidth = 50;
const sparkHeight = 16;
const padding = 1;
// Generate sparkline path
const sparklinePath = useMemo(() => {
if (dataPoints.length < 2) return '';
const minVal = min ?? Math.min(...dataPoints) * 0.9;
const maxVal = max ?? Math.max(...dataPoints) * 1.1;
const range = maxVal - minVal || 1;
const points = dataPoints.map((val, i) => {
const x = (i / (dataPoints.length - 1)) * (sparkWidth - padding * 2) + padding;
const y = sparkHeight - padding - ((val - minVal) / range) * (sparkHeight - padding * 2);
return `${x},${y}`;
});
return `M${points.join(' L')}`;
}, [dataPoints, min, max]);
// Gradient fill path (area under line)
const areaPath = useMemo(() => {
if (!sparklinePath) return '';
return `${sparklinePath} L${sparkWidth - padding},${sparkHeight - padding} L${padding},${sparkHeight - padding} Z`;
}, [sparklinePath]);
return (
<div className="flex flex-col items-center group relative">
{/* Label */}
<div className="text-[9px] text-slate-500 uppercase mb-0.5 tracking-wide">{label}</div>
{/* Value with trend indicator */}
<div className="flex items-center gap-0.5">
<span className={`text-sm font-bold ${colors.text}`}>
{decimals > 0 ? value.toFixed(decimals) : Math.round(value)}
</span>
<span className="text-[8px] text-slate-500">{unit}</span>
{/* Trend arrow */}
{showTrend && (
<span className="ml-0.5">
{trend === 'up' && <TrendingUp className="w-2.5 h-2.5 text-green-400" />}
{trend === 'down' && <TrendingDown className="w-2.5 h-2.5 text-red-400" />}
{trend === 'stable' && <Minus className="w-2.5 h-2.5 text-slate-500" />}
</span>
)}
</div>
{/* Sparkline */}
<svg
viewBox={`0 0 ${sparkWidth} ${sparkHeight}`}
className="w-full h-4 mt-0.5"
preserveAspectRatio="none"
>
{/* Gradient definition */}
<defs>
<linearGradient id={`spark-gradient-${label}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={colors.stroke} stopOpacity="0.3" />
<stop offset="100%" stopColor={colors.stroke} stopOpacity="0.05" />
</linearGradient>
</defs>
{/* Area fill */}
<path
d={areaPath}
fill={`url(#spark-gradient-${label})`}
/>
{/* Line */}
<path
d={sparklinePath}
fill="none"
stroke={colors.stroke}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Current value dot */}
{dataPoints.length > 0 && (
<circle
cx={sparkWidth - padding}
cy={(() => {
const minVal = min ?? Math.min(...dataPoints) * 0.9;
const maxVal = max ?? Math.max(...dataPoints) * 1.1;
const range = maxVal - minVal || 1;
return sparkHeight - padding - ((dataPoints[dataPoints.length - 1] - minVal) / range) * (sparkHeight - padding * 2);
})()}
r="2"
fill={colors.stroke}
className="animate-pulse"
/>
)}
</svg>
{/* Optimal indicator bar */}
<div className="w-full h-1 bg-slate-700/50 rounded-full mt-1 overflow-hidden relative">
{/* Score fill */}
<div
className={`h-full ${colors.bg} transition-all duration-500 rounded-full`}
style={{ width: `${Math.min(100, score * 100)}%` }}
/>
{/* Optimal marker */}
<div
className="absolute top-0 w-0.5 h-1 bg-white/30"
style={{ left: '70%' }}
title={`Optimal: ${optimal}${unit}`}
/>
</div>
{/* Tooltip on hover */}
<div className="absolute -top-12 left-1/2 -translate-x-1/2 bg-slate-800 border border-slate-600 rounded px-2 py-1 text-[10px] text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 shadow-lg">
Optimal: {optimal}{unit} Range: {dataPoints.length > 0 ? Math.min(...dataPoints).toFixed(decimals) : '?'} - {dataPoints.length > 0 ? Math.max(...dataPoints).toFixed(decimals) : '?'}
</div>
</div>
);
}
// Compact version for lists
export function VitalGaugeMini({ value, optimal, status }: { value: number; optimal: number; status?: 'good' | 'warning' | 'critical' }) {
const diff = Math.abs(value - optimal);
const maxDiff = optimal * 0.3;
const score = Math.max(0, 1 - diff / maxDiff);
const computed = status || (score > 0.7 ? 'good' : score > 0.4 ? 'warning' : 'critical');
const colorClasses = {
good: 'bg-green-500',
warning: 'bg-yellow-500',
critical: 'bg-red-500',
};
return (
<div className="w-6 h-1 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full ${colorClasses[computed]} transition-all`}
style={{ width: `${Math.min(100, score * 100)}%` }}
/>
</div>
);
}
// Group of vitals for a plant/section
interface VitalsGridProps {
health?: number;
temperature?: number;
humidity?: number;
vpd?: number;
compact?: boolean;
}
export function VitalsGrid({ health = 85, temperature = 76, humidity = 60, vpd = 1.2, compact = false }: VitalsGridProps) {
if (compact) {
return (
<div className="flex gap-1">
<VitalGaugeMini value={health} optimal={85} />
<VitalGaugeMini value={temperature} optimal={76} />
<VitalGaugeMini value={humidity} optimal={60} />
<VitalGaugeMini value={vpd} optimal={1.2} />
</div>
);
}
return (
<div className="grid grid-cols-4 gap-2">
<VitalGauge label="Health" value={health} unit="%" optimal={85} />
<VitalGauge label="Temp" value={temperature} unit="°F" optimal={76} />
<VitalGauge label="RH" value={humidity} unit="%" optimal={60} />
<VitalGauge label="VPD" value={vpd} unit="kPa" optimal={1.2} decimals={2} />
</div>
);
}