From 93a39c2f2c8827cdb655e6351e3036da5f37bbdf Mon Sep 17 00:00:00 2001
From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com>
Date: Fri, 12 Dec 2025 16:55:04 -0800
Subject: [PATCH] feat: BatchDetailPage with rich data visualization
- BatchDetailPage: Lifecycle journey, SVG sparkline charts, modeled sensor data
- Metrics: Temperature, humidity, VPD, CO2, light PPFD with trends
- Touch point history with categorized icons
- Health score gauge visualization
- IPM schedule display
- BatchesPage: Clickable cards linking to detail
- Stage progress mini indicator
- Days-in-cycle badge
---
frontend/src/pages/BatchDetailPage.tsx | 419 +++++++++++++++++++++++++
frontend/src/pages/BatchesPage.tsx | 163 +++++-----
frontend/src/router.tsx | 5 +
3 files changed, 516 insertions(+), 71 deletions(-)
create mode 100644 frontend/src/pages/BatchDetailPage.tsx
diff --git a/frontend/src/pages/BatchDetailPage.tsx b/frontend/src/pages/BatchDetailPage.tsx
new file mode 100644
index 0000000..46b0921
--- /dev/null
+++ b/frontend/src/pages/BatchDetailPage.tsx
@@ -0,0 +1,419 @@
+import { useState, useEffect } from 'react';
+import { useParams, useNavigate, Link } from 'react-router-dom';
+import {
+ ArrowLeft, Calendar, Droplets, Thermometer, Wind, Zap,
+ Bug, Camera, Activity, TrendingUp, Clock, CheckCircle,
+ AlertTriangle, Leaf, Sun, Moon
+} from 'lucide-react';
+import { batchesApi, Batch } from '../lib/batchesApi';
+
+// Mock sensor data generator
+function generateMockData(days: number, min: number, max: number, variance: number = 5) {
+ return Array.from({ length: days }, (_, i) => ({
+ day: i + 1,
+ value: min + Math.random() * (max - min) + (Math.random() - 0.5) * variance,
+ timestamp: new Date(Date.now() - (days - i) * 24 * 60 * 60 * 1000).toISOString()
+ }));
+}
+
+// SVG Sparkline Component
+function Sparkline({ data, color = '#3B82F6', height = 40 }: {
+ data: { value: number }[];
+ color?: string;
+ height?: number;
+}) {
+ if (data.length === 0) return null;
+
+ const values = data.map(d => d.value);
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ const range = max - min || 1;
+
+ const width = 120;
+ const padding = 2;
+
+ const points = data.map((d, i) => {
+ const x = padding + (i / (data.length - 1)) * (width - padding * 2);
+ const y = height - padding - ((d.value - min) / range) * (height - padding * 2);
+ return `${x},${y}`;
+ }).join(' ');
+
+ return (
+
+ );
+}
+
+// Stage Progress Component
+function StageProgress({ currentStage }: { currentStage: string }) {
+ const stages = [
+ { id: 'CLONE_IN', label: 'Clone', icon: '🌱', days: 7 },
+ { id: 'VEGETATIVE', label: 'Veg', icon: '🌿', days: 28 },
+ { id: 'FLOWERING', label: 'Flower', icon: '🌸', days: 63 },
+ { id: 'HARVEST', label: 'Harvest', icon: '✂️', days: 1 },
+ { id: 'DRYING', label: 'Dry', icon: '🍂', days: 14 },
+ { id: 'CURING', label: 'Cure', icon: '🫙', days: 30 },
+ ];
+
+ const currentIndex = stages.findIndex(s => s.id === currentStage);
+
+ return (
+
+ {stages.map((stage, i) => {
+ const isPast = i < currentIndex;
+ const isCurrent = i === currentIndex;
+ const isFuture = i > currentIndex;
+
+ return (
+
+
+ {stage.icon}
+ {stage.label}
+
+ {i < stages.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+// Metric Card with Sparkline
+function MetricCard({ icon: Icon, label, value, unit, trend, data, color }: {
+ icon: typeof Thermometer;
+ label: string;
+ value: string;
+ unit: string;
+ trend?: 'up' | 'down' | 'stable';
+ data: { value: number }[];
+ color: string;
+}) {
+ return (
+
+
+
+ {trend && (
+
+ {trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'}
+
+ )}
+
+
+
+ {value}
+ {unit}
+
+
+
+
+ );
+}
+
+// Touch Point Item
+function TouchPoint({ type, date, user, notes }: {
+ type: string;
+ date: string;
+ user: string;
+ notes?: string;
+}) {
+ const typeConfig: Record = {
+ WATER: { icon: Droplets, color: 'text-blue-500' },
+ FEED: { icon: Leaf, color: 'text-green-500' },
+ INSPECT: { icon: Bug, color: 'text-amber-500' },
+ PHOTO: { icon: Camera, color: 'text-purple-500' },
+ DEFOLIATE: { icon: Leaf, color: 'text-orange-500' },
+ TRANSPLANT: { icon: Activity, color: 'text-teal-500' },
+ };
+
+ const config = typeConfig[type] || { icon: Activity, color: 'text-secondary' };
+ const Icon = config.icon;
+
+ return (
+
+
+
+
+
+
+ {type}
+ {new Date(date).toLocaleDateString()}
+
+ {notes &&
{notes}
}
+
{user}
+
+
+ );
+}
+
+export default function BatchDetailPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const [batch, setBatch] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ // Mock sensor data
+ const [sensorData] = useState(() => ({
+ temperature: generateMockData(14, 72, 78),
+ humidity: generateMockData(14, 50, 65),
+ vpd: generateMockData(14, 0.8, 1.2),
+ co2: generateMockData(14, 800, 1200),
+ lightPPFD: generateMockData(14, 600, 900),
+ }));
+
+ // Mock touch points
+ const [touchPoints] = useState(() => [
+ { type: 'INSPECT', date: new Date().toISOString(), user: 'Alex', notes: 'Looking healthy' },
+ { type: 'WATER', date: new Date(Date.now() - 86400000).toISOString(), user: 'Jordan' },
+ { type: 'FEED', date: new Date(Date.now() - 172800000).toISOString(), user: 'Alex', notes: 'Week 3 flower nutrients' },
+ { type: 'PHOTO', date: new Date(Date.now() - 259200000).toISOString(), user: 'Sam', notes: 'Progress photo' },
+ { type: 'INSPECT', date: new Date(Date.now() - 345600000).toISOString(), user: 'Jordan', notes: 'No issues' },
+ ]);
+
+ useEffect(() => {
+ if (id) {
+ batchesApi.getById(id)
+ .then(setBatch)
+ .catch(() => navigate('/batches'))
+ .finally(() => setLoading(false));
+ }
+ }, [id, navigate]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!batch) {
+ return (
+
+
Batch not found
+
← Back to batches
+
+ );
+ }
+
+ // Calculate days in stage
+ const startDate = new Date(batch.startDate);
+ const daysInCycle = Math.floor((Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24));
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
{batch.name}
+ {batch.stage}
+
+
+ {batch.strain}
+ •
+ {batch.plantCount} plants
+ •
+
+
+ Day {daysInCycle}
+
+
+
+
+
+ {/* Stage Journey */}
+
+
Lifecycle
+
+
+
+ {/* Sensor Grid */}
+
+
Environment (14 days)
+
+
+
+
+
+
+
+
+
+ {/* Two Column Layout */}
+
+ {/* Touch Points */}
+
+
+
Recent Activity
+ {touchPoints.length} entries
+
+
+ {touchPoints.map((tp, i) => (
+
+ ))}
+
+
+
+ {/* Stats & IPM */}
+
+ {/* Quick Stats */}
+
+
Statistics
+
+
+
+
{touchPoints.length}
+
Touch Points
+
+
+
+
+
+ {/* IPM Schedule */}
+ {batch.ipmSchedule ? (
+
+
IPM Schedule
+
+
+
+
+
+
{batch.ipmSchedule.product || 'Preventative'}
+
+ Next: {new Date(batch.ipmSchedule.nextTreatment).toLocaleDateString()}
+
+
+
+
+ ) : (
+
+
IPM Schedule
+
No IPM schedule configured
+
+ )}
+
+ {/* Health Score */}
+
+
Health Score
+
+
+
+
Excellent
+
Based on 14 day average
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/BatchesPage.tsx b/frontend/src/pages/BatchesPage.tsx
index 7c4addf..461ca09 100644
--- a/frontend/src/pages/BatchesPage.tsx
+++ b/frontend/src/pages/BatchesPage.tsx
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
-import { Plus, MoveRight, Sprout, Leaf, Flower, Archive, Scale, ClipboardList, Bug, Search } from 'lucide-react';
+import { Link } from 'react-router-dom';
+import { Plus, MoveRight, Sprout, Leaf, Flower, Archive, Scale, ClipboardList, Bug, Search, ChevronRight, Calendar } from 'lucide-react';
import { Batch, batchesApi } from '../lib/batchesApi';
import { useToast } from '../context/ToastContext';
import BatchTransitionModal from '../components/BatchTransitionModal';
@@ -11,6 +12,16 @@ import { PullToRefresh } from '../components/ui/PullToRefresh';
import { QuickLogBar } from '../components/touchpoints/QuickLogButtons';
import { PageHeader, SectionHeader, EmptyState, ActionButton, CardSkeleton } from '../components/ui/LinearPrimitives';
+const STAGE_CONFIG = {
+ CLONE_IN: { label: 'Clone', icon: Sprout, color: 'bg-blue-500', order: 0 },
+ VEGETATIVE: { label: 'Veg', icon: Leaf, color: 'bg-green-500', order: 1 },
+ FLOWERING: { label: 'Flower', icon: Flower, color: 'bg-purple-500', order: 2 },
+ HARVEST: { label: 'Harvest', icon: Archive, color: 'bg-amber-500', order: 3 },
+ DRYING: { label: 'Dry', icon: Archive, color: 'bg-orange-500', order: 4 },
+ CURING: { label: 'Cure', icon: Archive, color: 'bg-stone-500', order: 5 },
+ FINISHED: { label: 'Done', icon: Archive, color: 'bg-neutral-400', order: 6 },
+};
+
const STAGE_GROUPS = [
{ id: 'CLONE_IN', label: 'Clones', icon: Sprout, accent: 'accent' as const },
{ id: 'VEGETATIVE', label: 'Vegetative', icon: Leaf, accent: 'success' as const },
@@ -20,6 +31,37 @@ const STAGE_GROUPS = [
{ id: 'FINISHED', label: 'Finished', icon: Archive, accent: 'default' as const },
];
+// Stage Progress Mini Component
+function StageProgressMini({ currentStage }: { currentStage: string }) {
+ const stages = ['CLONE_IN', 'VEGETATIVE', 'FLOWERING', 'HARVEST', 'DRYING', 'CURING'];
+ const currentIndex = stages.indexOf(currentStage);
+
+ return (
+
+ {stages.map((stage, i) => (
+
+ ))}
+
+ );
+}
+
+// Days Badge
+function DaysBadge({ startDate }: { startDate: string }) {
+ const days = Math.floor((Date.now() - new Date(startDate).getTime()) / (1000 * 60 * 60 * 24));
+ return (
+
+
+ Day {days}
+
+ );
+}
+
export default function BatchesPage() {
const { addToast } = useToast();
const [batches, setBatches] = useState([]);
@@ -71,9 +113,9 @@ export default function BatchesPage() {
}
/>
-
+
{loading ? (
-
+
{Array.from({ length: 6 }).map((_, i) => )}
) : groupedBatches.length === 0 ? (
@@ -100,22 +142,41 @@ export default function BatchesPage() {
{group.items.map(batch => (
-
-
-
-
- {batch.name}
-
-
{batch.strain}
+
+ {/* Clickable Header */}
+
+
+
+
+ {batch.name}
+
+
+ {batch.strain}
+ •
+ {batch.plantCount} plants
+
+
+
-
+
+ {/* Progress & Days */}
+
+
+
+
+
+
+ {/* Quick Actions */}
+
+
{batch.room?.name || 'No Room'}
+
setCreateTaskBatch(batch)}
+ icon={Search}
+ label="Scout"
+ onClick={() => setScoutingBatch(batch)}
variant="accent"
/>
setIpmBatch(batch)}
variant="destructive"
/>
- setScoutingBatch(batch)}
- variant="accent"
- />
- {['HARVEST', 'DRYING', 'CURING', 'FINISHED'].includes(batch.stage) && (
- setWeightLogBatch(batch)}
- variant="accent"
- />
- )}
-
-
-
- Room
-
- {batch.room?.name || 'Unassigned'}
-
-
-
- Plants
-
- {batch.plantCount}
-
-
-
-
-
- fetchBatches()}
- compact
- />
-
))}
@@ -178,6 +201,7 @@ export default function BatchesPage() {
)}
+ {/* Modals */}
{selectedBatch && (
)}
-
{weightLogBatch && (
setWeightLogBatch(null)}
+ onSuccess={() => fetchBatches()}
+ />
+ )}
+ {createTaskBatch && (
+ setCreateTaskBatch(null)}
onSuccess={() => {
- fetchBatches();
- setWeightLogBatch(null);
+ setCreateTaskBatch(null);
+ addToast('Task created!', 'success');
}}
/>
)}
-
- {createTaskBatch && (
- setCreateTaskBatch(null)}
- onSuccess={() => setCreateTaskBatch(null)}
- />
- )}
-
{ipmBatch && (
setIpmBatch(null)}
/>
)}
-
{scoutingBatch && (
setScoutingBatch(null)}
- onSuccess={() => { }}
+ onSuccess={() => {
+ fetchBatches();
+ addToast('Scouting report logged', 'success');
+ }}
/>
)}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
index 542f1f2..c0a5f4a 100644
--- a/frontend/src/router.tsx
+++ b/frontend/src/router.tsx
@@ -12,6 +12,7 @@ import DashboardPage from './pages/DashboardPage';
const DailyWalkthroughPage = lazy(() => import('./pages/DailyWalkthroughPage'));
const RoomsPage = lazy(() => import('./pages/RoomsPage'));
const BatchesPage = lazy(() => import('./pages/BatchesPage'));
+const BatchDetailPage = lazy(() => import('./pages/BatchDetailPage'));
const TimeclockPage = lazy(() => import('./pages/TimeclockPage'));
const SuppliesPage = lazy(() => import('./pages/SuppliesPage'));
const TasksPage = lazy(() => import('./pages/TasksPage'));
@@ -110,6 +111,10 @@ export const router = createBrowserRouter([
path: 'batches',
element:
}>
,
},
+ {
+ path: 'batches/:id',
+ element:
}>
,
+ },
{
path: 'timeclock',
element:
}>
,