feat: Sprint 3 - SVG Lifecycle Timeline
- LifecycleTimeline.tsx: Full SVG-animated component - 6 stage nodes with connecting lines - Current stage pulsing animation w/ glow - Completed stages show checkmarks - Day tracking and harvest estimation - LifecycleTimelineMini: Compact version for lists - Updated PlantDataCard to use new component
This commit is contained in:
parent
f98c97637a
commit
097b2ba0c8
2 changed files with 242 additions and 52 deletions
239
frontend/src/components/facility3d/LifecycleTimeline.tsx
Normal file
239
frontend/src/components/facility3d/LifecycleTimeline.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
// Growth stages in order
|
||||||
|
export const LIFECYCLE_STAGES = [
|
||||||
|
{ key: 'CLONE_IN', label: 'Clone', color: '#3b82f6', icon: '🌱' },
|
||||||
|
{ key: 'VEGETATIVE', label: 'Veg', color: '#22c55e', icon: '🌿' },
|
||||||
|
{ key: 'FLOWERING', label: 'Flower', color: '#a855f7', icon: '🌸' },
|
||||||
|
{ key: 'DRYING', label: 'Dry', color: '#f97316', icon: '🍂' },
|
||||||
|
{ key: 'CURING', label: 'Cure', color: '#92400e', icon: '🫙' },
|
||||||
|
{ key: 'FINISHED', label: 'Done', color: '#6b7280', icon: '✓' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface LifecycleTimelineProps {
|
||||||
|
currentStage?: string;
|
||||||
|
startDate?: Date;
|
||||||
|
stageHistory?: Array<{
|
||||||
|
stage: string;
|
||||||
|
enteredAt: Date;
|
||||||
|
exitedAt?: Date;
|
||||||
|
}>;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LifecycleTimeline({
|
||||||
|
currentStage = 'VEGETATIVE',
|
||||||
|
startDate,
|
||||||
|
stageHistory,
|
||||||
|
compact = false
|
||||||
|
}: LifecycleTimelineProps) {
|
||||||
|
|
||||||
|
// Find current stage index
|
||||||
|
const currentIndex = useMemo(() => {
|
||||||
|
const normalized = currentStage?.toUpperCase().replace(/ /g, '_');
|
||||||
|
const idx = LIFECYCLE_STAGES.findIndex(s => s.key === normalized);
|
||||||
|
return idx >= 0 ? idx : 1; // Default to VEG if not found
|
||||||
|
}, [currentStage]);
|
||||||
|
|
||||||
|
// Calculate days in current stage
|
||||||
|
const daysInStage = useMemo(() => {
|
||||||
|
if (!startDate) return Math.floor(Math.random() * 21) + 7; // Mock 7-28 days
|
||||||
|
return Math.floor((Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
}, [startDate]);
|
||||||
|
|
||||||
|
// Estimate total grow days and remaining
|
||||||
|
const totalDays = useMemo(() => {
|
||||||
|
// Typical grow times per stage
|
||||||
|
const typicalDays: Record<string, number> = {
|
||||||
|
CLONE_IN: 14,
|
||||||
|
VEGETATIVE: 28,
|
||||||
|
FLOWERING: 56,
|
||||||
|
DRYING: 10,
|
||||||
|
CURING: 21,
|
||||||
|
FINISHED: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i <= currentIndex; i++) {
|
||||||
|
total += typicalDays[LIFECYCLE_STAGES[i].key] || 14;
|
||||||
|
}
|
||||||
|
return total - typicalDays[LIFECYCLE_STAGES[currentIndex].key] + daysInStage;
|
||||||
|
}, [currentIndex, daysInStage]);
|
||||||
|
|
||||||
|
const estimatedHarvest = useMemo(() => {
|
||||||
|
// Estimate remaining days
|
||||||
|
const typicalDays: Record<string, number> = {
|
||||||
|
CLONE_IN: 14,
|
||||||
|
VEGETATIVE: 28,
|
||||||
|
FLOWERING: 56,
|
||||||
|
DRYING: 10,
|
||||||
|
CURING: 21,
|
||||||
|
FINISHED: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let remaining = 0;
|
||||||
|
for (let i = currentIndex; i < LIFECYCLE_STAGES.length - 1; i++) {
|
||||||
|
const stageDays = typicalDays[LIFECYCLE_STAGES[i].key] || 14;
|
||||||
|
if (i === currentIndex) {
|
||||||
|
remaining += Math.max(0, stageDays - daysInStage);
|
||||||
|
} else {
|
||||||
|
remaining += stageDays;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(Date.now() + remaining * 24 * 60 * 60 * 1000);
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}, [currentIndex, daysInStage]);
|
||||||
|
|
||||||
|
// SVG dimensions
|
||||||
|
const width = compact ? 200 : 260;
|
||||||
|
const height = compact ? 40 : 50;
|
||||||
|
const nodeRadius = compact ? 6 : 8;
|
||||||
|
const padding = 20;
|
||||||
|
const nodeSpacing = (width - padding * 2) / (LIFECYCLE_STAGES.length - 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* SVG Timeline */}
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
className="w-full"
|
||||||
|
style={{ maxHeight: height }}
|
||||||
|
>
|
||||||
|
{/* Connecting lines */}
|
||||||
|
{LIFECYCLE_STAGES.map((stage, i) => {
|
||||||
|
if (i === LIFECYCLE_STAGES.length - 1) return null;
|
||||||
|
|
||||||
|
const x1 = padding + i * nodeSpacing;
|
||||||
|
const x2 = padding + (i + 1) * nodeSpacing;
|
||||||
|
const y = height / 2;
|
||||||
|
|
||||||
|
const isCompleted = i < currentIndex;
|
||||||
|
const isCurrent = i === currentIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`line-${i}`}
|
||||||
|
x1={x1 + nodeRadius}
|
||||||
|
y1={y}
|
||||||
|
x2={x2 - nodeRadius}
|
||||||
|
y2={y}
|
||||||
|
stroke={isCompleted ? '#22c55e' : '#334155'}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray={isCompleted ? 'none' : '3,3'}
|
||||||
|
className={isCurrent ? 'animate-pulse' : ''}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Stage nodes */}
|
||||||
|
{LIFECYCLE_STAGES.map((stage, i) => {
|
||||||
|
const x = padding + i * nodeSpacing;
|
||||||
|
const y = height / 2;
|
||||||
|
|
||||||
|
const isCompleted = i < currentIndex;
|
||||||
|
const isCurrent = i === currentIndex;
|
||||||
|
const isFuture = i > currentIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={stage.key}>
|
||||||
|
{/* Outer ring for current stage */}
|
||||||
|
{isCurrent && (
|
||||||
|
<circle
|
||||||
|
cx={x}
|
||||||
|
cy={y}
|
||||||
|
r={nodeRadius + 4}
|
||||||
|
fill="none"
|
||||||
|
stroke="#22d3ee"
|
||||||
|
strokeWidth={2}
|
||||||
|
className="animate-ping"
|
||||||
|
style={{
|
||||||
|
animationDuration: '1.5s',
|
||||||
|
transformOrigin: `${x}px ${y}px`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main node */}
|
||||||
|
<circle
|
||||||
|
cx={x}
|
||||||
|
cy={y}
|
||||||
|
r={nodeRadius}
|
||||||
|
fill={isCompleted || isCurrent ? stage.color : 'transparent'}
|
||||||
|
stroke={isFuture ? '#475569' : stage.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
className={`transition-all duration-300 ${isCurrent ? 'drop-shadow-lg' : ''}`}
|
||||||
|
style={isCurrent ? { filter: 'drop-shadow(0 0 6px rgba(34, 211, 238, 0.5))' } : {}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Check mark for completed */}
|
||||||
|
{isCompleted && (
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={y + 1}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fill="white"
|
||||||
|
fontSize={compact ? 8 : 10}
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stage label (below) */}
|
||||||
|
{!compact && (
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={y + nodeRadius + 12}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={isCurrent ? '#22d3ee' : isFuture ? '#64748b' : '#94a3b8'}
|
||||||
|
fontSize={8}
|
||||||
|
fontWeight={isCurrent ? 'bold' : 'normal'}
|
||||||
|
>
|
||||||
|
{stage.label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Day info */}
|
||||||
|
<div className={`flex justify-between items-center ${compact ? 'text-[9px]' : 'text-[10px]'} text-slate-400 mt-1`}>
|
||||||
|
<span>
|
||||||
|
Day <span className="text-cyan-400 font-bold">{totalDays}</span>
|
||||||
|
{' • '}
|
||||||
|
<span className="text-slate-500">{daysInStage}d in {LIFECYCLE_STAGES[currentIndex]?.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">
|
||||||
|
Est. harvest: <span className="text-green-400">{estimatedHarvest}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mini version for list views
|
||||||
|
export function LifecycleTimelineMini({ currentStage }: { currentStage?: string }) {
|
||||||
|
const currentIndex = useMemo(() => {
|
||||||
|
const normalized = currentStage?.toUpperCase().replace(/ /g, '_');
|
||||||
|
const idx = LIFECYCLE_STAGES.findIndex(s => s.key === normalized);
|
||||||
|
return idx >= 0 ? idx : 1;
|
||||||
|
}, [currentStage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{LIFECYCLE_STAGES.map((stage, i) => (
|
||||||
|
<div
|
||||||
|
key={stage.key}
|
||||||
|
className={`w-2 h-2 rounded-full transition-all ${i < currentIndex ? 'bg-green-500' :
|
||||||
|
i === currentIndex ? 'bg-cyan-400 ring-2 ring-cyan-400/30' :
|
||||||
|
'bg-slate-700'
|
||||||
|
}`}
|
||||||
|
title={stage.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,12 @@
|
||||||
import { X, MapPin, Leaf, Activity, Calendar } from 'lucide-react';
|
import { X, MapPin, Leaf, Activity, Calendar } from 'lucide-react';
|
||||||
import type { PlantPosition } from './types';
|
import type { PlantPosition } from './types';
|
||||||
|
import { LifecycleTimeline } from './LifecycleTimeline';
|
||||||
|
|
||||||
interface PlantDataCardProps {
|
interface PlantDataCardProps {
|
||||||
plant: PlantPosition | null;
|
plant: PlantPosition | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock lifecycle data for demo
|
|
||||||
const getMockLifecycle = (stage?: string) => {
|
|
||||||
const stages = ['CLONE_IN', 'VEGETATIVE', 'FLOWERING', 'DRYING', 'CURING', 'FINISHED'];
|
|
||||||
const currentIndex = stages.indexOf(stage || 'VEGETATIVE');
|
|
||||||
|
|
||||||
return {
|
|
||||||
stages: stages.map((s, i) => ({
|
|
||||||
name: s.replace('_', ' '),
|
|
||||||
completed: i < currentIndex,
|
|
||||||
current: i === currentIndex,
|
|
||||||
dayCount: i * 14 + Math.floor(Math.random() * 7),
|
|
||||||
})),
|
|
||||||
startDate: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000), // 45 days ago
|
|
||||||
estimatedHarvest: new Date(Date.now() + 45 * 24 * 60 * 60 * 1000), // 45 days from now
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock vitals for demo
|
// Mock vitals for demo
|
||||||
const getMockVitals = () => ({
|
const getMockVitals = () => ({
|
||||||
health: 75 + Math.floor(Math.random() * 20),
|
health: 75 + Math.floor(Math.random() * 20),
|
||||||
|
|
@ -34,7 +18,6 @@ const getMockVitals = () => ({
|
||||||
export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
|
export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
|
||||||
if (!plant?.plant) return null;
|
if (!plant?.plant) return null;
|
||||||
|
|
||||||
const lifecycle = getMockLifecycle(plant.plant.stage ?? undefined);
|
|
||||||
const vitals = getMockVitals();
|
const vitals = getMockVitals();
|
||||||
|
|
||||||
// Format position breadcrumb
|
// Format position breadcrumb
|
||||||
|
|
@ -76,45 +59,13 @@ export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lifecycle Timeline */}
|
{/* Lifecycle Timeline - Using new SVG component */}
|
||||||
<div className="px-4 py-3 border-b border-slate-700/30">
|
<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">
|
<div className="text-xs text-slate-400 mb-2 flex items-center gap-1">
|
||||||
<Calendar className="w-3 h-3" />
|
<Calendar className="w-3 h-3" />
|
||||||
LIFECYCLE
|
LIFECYCLE
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<LifecycleTimeline currentStage={plant.plant.stage ?? undefined} />
|
||||||
{lifecycle.stages.map((stage, i) => (
|
|
||||||
<div
|
|
||||||
key={stage.name}
|
|
||||||
className="flex flex-col items-center"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`w-3 h-3 rounded-full border-2 transition-all ${stage.completed
|
|
||||||
? 'bg-green-500 border-green-500'
|
|
||||||
: stage.current
|
|
||||||
? 'bg-cyan-500 border-cyan-400 animate-pulse'
|
|
||||||
: 'bg-transparent border-slate-600'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{i < lifecycle.stages.length - 1 && (
|
|
||||||
<div className={`absolute w-8 h-0.5 -right-4 top-1.5 ${stage.completed ? 'bg-green-500/50' : 'bg-slate-700'
|
|
||||||
}`} style={{ display: 'none' }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-[8px] text-slate-500 uppercase">
|
|
||||||
<span>Clone</span>
|
|
||||||
<span>Veg</span>
|
|
||||||
<span>Flower</span>
|
|
||||||
<span>Dry</span>
|
|
||||||
<span>Cure</span>
|
|
||||||
<span>Done</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-[10px] text-slate-400">
|
|
||||||
Day {lifecycle.stages.find(s => s.current)?.dayCount || 0} •
|
|
||||||
Est. harvest: {lifecycle.estimatedHarvest.toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vitals Grid */}
|
{/* Vitals Grid */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue