diff --git a/frontend/src/components/facility3d/LifecycleTimeline.tsx b/frontend/src/components/facility3d/LifecycleTimeline.tsx new file mode 100644 index 0000000..1617da0 --- /dev/null +++ b/frontend/src/components/facility3d/LifecycleTimeline.tsx @@ -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 = { + 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 = { + 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 ( +
+ {/* SVG Timeline */} + + {/* 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 ( + + ); + })} + + {/* 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 ( + + {/* Outer ring for current stage */} + {isCurrent && ( + + )} + + {/* Main node */} + + + {/* Check mark for completed */} + {isCompleted && ( + + ✓ + + )} + + {/* Stage label (below) */} + {!compact && ( + + {stage.label} + + )} + + ); + })} + + + {/* Day info */} +
+ + Day {totalDays} + {' • '} + {daysInStage}d in {LIFECYCLE_STAGES[currentIndex]?.label} + + + Est. harvest: {estimatedHarvest} + +
+
+ ); +} + +// 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 ( +
+ {LIFECYCLE_STAGES.map((stage, i) => ( +
+ ))} +
+ ); +} diff --git a/frontend/src/components/facility3d/PlantDataCard.tsx b/frontend/src/components/facility3d/PlantDataCard.tsx index a9355b3..711e9c2 100644 --- a/frontend/src/components/facility3d/PlantDataCard.tsx +++ b/frontend/src/components/facility3d/PlantDataCard.tsx @@ -1,28 +1,12 @@ import { X, MapPin, Leaf, Activity, Calendar } from 'lucide-react'; import type { PlantPosition } from './types'; +import { LifecycleTimeline } from './LifecycleTimeline'; interface PlantDataCardProps { plant: PlantPosition | null; 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 const getMockVitals = () => ({ health: 75 + Math.floor(Math.random() * 20), @@ -34,7 +18,6 @@ const getMockVitals = () => ({ export function PlantDataCard({ plant, onClose }: PlantDataCardProps) { if (!plant?.plant) return null; - const lifecycle = getMockLifecycle(plant.plant.stage ?? undefined); const vitals = getMockVitals(); // Format position breadcrumb @@ -76,45 +59,13 @@ export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
- {/* Lifecycle Timeline */} + {/* Lifecycle Timeline - Using new SVG component */}
LIFECYCLE
-
- {lifecycle.stages.map((stage, i) => ( -
-
- {i < lifecycle.stages.length - 1 && ( -
- )} -
- ))} -
-
- Clone - Veg - Flower - Dry - Cure - Done -
-
- Day {lifecycle.stages.find(s => s.current)?.dayCount || 0} • - Est. harvest: {lifecycle.estimatedHarvest.toLocaleDateString()} -
+
{/* Vitals Grid */}