From 0cce5e23453b0879cfea14adb2260340c24e76e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 18:34:51 +0000 Subject: [PATCH] Add UI components for transport tracking, demand visualization, and analytics Transport components: - TransportTimeline: Visual timeline of transport events with status badges - JourneyMap: SVG-based map visualization of plant journey locations - CarbonFootprintCard: Carbon metrics display with comparison charts - QRCodeDisplay: QR code generation for traceability verification - TransportEventForm: Form for recording transport events Demand components: - DemandSignalCard: Regional demand signal with supply status indicators - PreferencesForm: Multi-section consumer preference input form - RecommendationList: Planting recommendations with risk assessment - SupplyGapChart: Supply vs demand visualization with gap indicators - SeasonalCalendar: Seasonal produce availability calendar view Analytics components: - EnvironmentalImpact: Comprehensive carbon and food miles analysis - FoodMilesTracker: Food miles tracking with daily charts and targets - SavingsCalculator: Environmental savings vs conventional agriculture All components follow existing patterns, use Tailwind CSS, and are fully typed. --- components/analytics/EnvironmentalImpact.tsx | 272 +++++++++++ components/analytics/FoodMilesTracker.tsx | 231 +++++++++ components/analytics/SavingsCalculator.tsx | 273 +++++++++++ components/analytics/index.ts | 5 + components/demand/DemandSignalCard.tsx | 163 +++++++ components/demand/PreferencesForm.tsx | 465 +++++++++++++++++++ components/demand/RecommendationList.tsx | 206 ++++++++ components/demand/SeasonalCalendar.tsx | 308 ++++++++++++ components/demand/SupplyGapChart.tsx | 187 ++++++++ components/demand/index.ts | 6 + components/transport/CarbonFootprintCard.tsx | 191 ++++++++ components/transport/JourneyMap.tsx | 231 +++++++++ components/transport/QRCodeDisplay.tsx | 217 +++++++++ components/transport/TransportEventForm.tsx | 324 +++++++++++++ components/transport/TransportTimeline.tsx | 181 ++++++++ components/transport/index.ts | 6 + 16 files changed, 3266 insertions(+) create mode 100644 components/analytics/EnvironmentalImpact.tsx create mode 100644 components/analytics/FoodMilesTracker.tsx create mode 100644 components/analytics/SavingsCalculator.tsx create mode 100644 components/analytics/index.ts create mode 100644 components/demand/DemandSignalCard.tsx create mode 100644 components/demand/PreferencesForm.tsx create mode 100644 components/demand/RecommendationList.tsx create mode 100644 components/demand/SeasonalCalendar.tsx create mode 100644 components/demand/SupplyGapChart.tsx create mode 100644 components/demand/index.ts create mode 100644 components/transport/CarbonFootprintCard.tsx create mode 100644 components/transport/JourneyMap.tsx create mode 100644 components/transport/QRCodeDisplay.tsx create mode 100644 components/transport/TransportEventForm.tsx create mode 100644 components/transport/TransportTimeline.tsx create mode 100644 components/transport/index.ts diff --git a/components/analytics/EnvironmentalImpact.tsx b/components/analytics/EnvironmentalImpact.tsx new file mode 100644 index 0000000..a7fa4fe --- /dev/null +++ b/components/analytics/EnvironmentalImpact.tsx @@ -0,0 +1,272 @@ +import { EnvironmentalImpact as ImpactData, TransportEventType, TransportMethod } from '../../lib/transport/types'; + +interface EnvironmentalImpactProps { + impact: ImpactData; + title?: string; + showDetails?: boolean; +} + +const METHOD_LABELS: Record = { + walking: 'Walking', + bicycle: 'Bicycle', + electric_vehicle: 'Electric Vehicle', + hybrid_vehicle: 'Hybrid Vehicle', + gasoline_vehicle: 'Gas Vehicle', + diesel_truck: 'Diesel Truck', + electric_truck: 'Electric Truck', + refrigerated_truck: 'Refrigerated Truck', + rail: 'Rail', + ship: 'Ship', + air: 'Air Freight', + drone: 'Drone', + local_delivery: 'Local Delivery', + customer_pickup: 'Customer Pickup', +}; + +const EVENT_LABELS: Record = { + seed_acquisition: 'Seed Acquisition', + planting: 'Planting', + growing_transport: 'Growing Transport', + harvest: 'Harvest', + processing: 'Processing', + distribution: 'Distribution', + consumer_delivery: 'Consumer Delivery', + seed_saving: 'Seed Saving', + seed_sharing: 'Seed Sharing', +}; + +function getCarbonRating(carbon: number): { label: string; color: string; bgColor: string } { + if (carbon < 0.5) return { label: 'Excellent', color: 'text-green-600', bgColor: 'bg-green-100' }; + if (carbon < 1) return { label: 'Very Good', color: 'text-emerald-600', bgColor: 'bg-emerald-100' }; + if (carbon < 2) return { label: 'Good', color: 'text-lime-600', bgColor: 'bg-lime-100' }; + if (carbon < 5) return { label: 'Moderate', color: 'text-yellow-600', bgColor: 'bg-yellow-100' }; + if (carbon < 10) return { label: 'High', color: 'text-orange-600', bgColor: 'bg-orange-100' }; + return { label: 'Very High', color: 'text-red-600', bgColor: 'bg-red-100' }; +} + +export default function EnvironmentalImpact({ + impact, + title = 'Environmental Impact Report', + showDetails = true, +}: EnvironmentalImpactProps) { + const rating = getCarbonRating(impact.totalCarbonKg); + + // Sort methods by carbon impact + const sortedMethods = Object.entries(impact.breakdownByMethod) + .filter(([_, data]) => data.carbon > 0 || data.distance > 0) + .sort((a, b) => b[1].carbon - a[1].carbon); + + // Sort events by count + const sortedEvents = Object.entries(impact.breakdownByEventType) + .filter(([_, data]) => data.count > 0) + .sort((a, b) => b[1].count - a[1].count); + + const totalMethods = sortedMethods.length; + const totalEvents = sortedEvents.reduce((sum, [_, data]) => sum + data.count, 0); + + return ( +
+ {/* Header */} +
+

{title}

+

Complete carbon and food miles analysis

+
+ +
+ {/* Main metrics */} +
+
+

{impact.totalCarbonKg.toFixed(2)}

+

Total Carbon (kg CO2)

+
+
+

{impact.totalFoodMiles.toFixed(1)}

+

Total Food Miles (km)

+
+
+

{impact.carbonPerKgProduce.toFixed(3)}

+

kg CO2 / kg Produce

+
+
+

{impact.milesPerKgProduce.toFixed(1)}

+

Miles / kg Produce

+
+
+ + {/* Rating badge */} +
+
+ + Environmental Rating: {rating.label} + +
+
+ + {/* Comparison with conventional */} + {impact.comparisonToConventional && ( +
+

+ Comparison vs Conventional Agriculture +

+ +
+
+
+ + {impact.comparisonToConventional.percentageReduction.toFixed(0)}% + +
+

Carbon Reduction

+
+ +
+

+ {impact.comparisonToConventional.carbonSaved.toFixed(1)} +

+

kg CO2 Saved

+
+ 🌲 + + = {(impact.comparisonToConventional.carbonSaved / 21).toFixed(1)} trees/year + +
+
+ +
+

+ {impact.comparisonToConventional.milesSaved.toFixed(0)} +

+

km Saved

+
+ πŸš— + + = {(impact.comparisonToConventional.milesSaved / 15).toFixed(0)} car trips + +
+
+
+ + {/* Visual bar comparison */} +
+
+
+ Your Carbon Footprint + {impact.totalCarbonKg.toFixed(2)} kg +
+
+
+
+
+
+
+ Conventional Average + + {(impact.totalCarbonKg + impact.comparisonToConventional.carbonSaved).toFixed(2)} kg + +
+
+
+
+
+
+
+ )} + + {/* Detailed breakdowns */} + {showDetails && ( +
+ {/* By Transport Method */} + {sortedMethods.length > 0 && ( +
+

By Transport Method

+
+ {sortedMethods.map(([method, data]) => { + const percentage = impact.totalCarbonKg > 0 + ? (data.carbon / impact.totalCarbonKg) * 100 + : 0; + return ( +
+
+ + {METHOD_LABELS[method as TransportMethod] || method} + + + {data.carbon.toFixed(3)} kg ({percentage.toFixed(0)}%) + +
+
+
+
+

+ {data.distance.toFixed(1)} km traveled +

+
+ ); + })} +
+
+ )} + + {/* By Event Type */} + {sortedEvents.length > 0 && ( +
+

By Event Type

+
+ {sortedEvents.map(([eventType, data]) => { + const percentage = totalEvents > 0 + ? (data.count / totalEvents) * 100 + : 0; + return ( +
+
+ + {EVENT_LABELS[eventType as TransportEventType] || eventType} + + + {data.count} events β€’ {data.carbon.toFixed(3)} kg + +
+
+
+
+
+ ); + })} +
+
+ )} +
+ )} + + {/* Tips Section */} +
+

Tips to Reduce Impact

+
    +
  • β€’ Prefer walking, cycling, or electric vehicles for short distances
  • +
  • β€’ Consolidate multiple transports into single trips
  • +
  • β€’ Source from local producers within 25km when possible
  • +
  • β€’ Use rail transport for longer distances when available
  • +
  • β€’ Avoid air freight unless absolutely necessary
  • +
+
+ + {/* Summary stats */} +
+ Transport methods used: {totalMethods} + Total events tracked: {totalEvents} +
+
+
+ ); +} diff --git a/components/analytics/FoodMilesTracker.tsx b/components/analytics/FoodMilesTracker.tsx new file mode 100644 index 0000000..3324415 --- /dev/null +++ b/components/analytics/FoodMilesTracker.tsx @@ -0,0 +1,231 @@ +import { useState } from 'react'; + +interface FoodMilesTrackerProps { + data: FoodMilesData[]; + currentTotal: number; + targetMiles?: number; +} + +export interface FoodMilesData { + date: string; + miles: number; + carbonKg: number; + eventType?: string; + produceType?: string; +} + +type TimeRange = '7d' | '30d' | '90d' | '365d'; + +export default function FoodMilesTracker({ + data, + currentTotal, + targetMiles = 50, +}: FoodMilesTrackerProps) { + const [timeRange, setTimeRange] = useState('30d'); + + // Filter data based on time range + const filterData = (range: TimeRange): FoodMilesData[] => { + const now = new Date(); + const daysMap: Record = { + '7d': 7, + '30d': 30, + '90d': 90, + '365d': 365, + }; + const cutoff = new Date(now.getTime() - daysMap[range] * 24 * 60 * 60 * 1000); + return data.filter((d) => new Date(d.date) >= cutoff); + }; + + const filteredData = filterData(timeRange); + + // Calculate stats for filtered period + const totalMiles = filteredData.reduce((sum, d) => sum + d.miles, 0); + const totalCarbon = filteredData.reduce((sum, d) => sum + d.carbonKg, 0); + const avgMilesPerDay = filteredData.length > 0 ? totalMiles / filteredData.length : 0; + + // Group data by day for chart + const dailyData: Record = {}; + filteredData.forEach((d) => { + const day = d.date.split('T')[0]; + if (!dailyData[day]) { + dailyData[day] = { miles: 0, carbon: 0 }; + } + dailyData[day].miles += d.miles; + dailyData[day].carbon += d.carbonKg; + }); + + const chartData = Object.entries(dailyData).sort((a, b) => a[0].localeCompare(b[0])); + const maxMiles = Math.max(...chartData.map(([_, d]) => d.miles), 1); + + // Progress towards target + const progressPercentage = Math.min((currentTotal / targetMiles) * 100, 100); + const isOverTarget = currentTotal > targetMiles; + + // Recent events + const recentEvents = [...data].sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() + ).slice(0, 5); + + return ( +
+ {/* Header */} +
+

Food Miles Tracker

+

Monitor your produce transportation distances

+
+ +
+ {/* Time range selector */} +
+
+ {(['7d', '30d', '90d', '365d'] as TimeRange[]).map((range) => ( + + ))} +
+
+ + {/* Current total with target */} +
+
+
+

Current Period Total

+

+ {totalMiles.toFixed(1)} km +

+
+
+

Target

+

{targetMiles} km

+
+
+ + {/* Progress bar */} +
+
+
+
+ {/* Target marker */} +
+
Target
+
+
+ +

+ {isOverTarget + ? `${(currentTotal - targetMiles).toFixed(1)} km over target` + : `${(targetMiles - currentTotal).toFixed(1)} km remaining to target`} +

+
+ + {/* Stats row */} +
+
+

{filteredData.length}

+

Transport Events

+
+
+

{totalCarbon.toFixed(2)}

+

kg CO2 Total

+
+
+

{avgMilesPerDay.toFixed(1)}

+

Avg km/day

+
+
+ + {/* Simple bar chart */} + {chartData.length > 0 && ( +
+

Daily Food Miles

+
+ {chartData.slice(-14).map(([date, values], index) => { + const height = (values.miles / maxMiles) * 100; + return ( +
+
+
+
+
+ {new Date(date).getDate()} +
+
+ ); + })} +
+

Last 14 days

+
+ )} + + {/* Recent events */} + {recentEvents.length > 0 && ( +
+

Recent Transport Events

+
+ {recentEvents.map((event, index) => ( +
+
+

+ {event.produceType || event.eventType || 'Transport'} +

+

+ {new Date(event.date).toLocaleDateString()} +

+
+
+

{event.miles.toFixed(1)} km

+

{event.carbonKg.toFixed(3)} kg CO2

+
+
+ ))} +
+
+ )} + + {/* Comparison info */} +
+

Did you know?

+

+ The average food item in conventional supply chains travels 2,400 km before reaching your plate. + LocalGreenChain helps reduce this by connecting you with local growers. +

+
+ Your average: {avgMilesPerDay.toFixed(1)} km/day vs Conventional: 6.6 km/day + {avgMilesPerDay < 6.6 && ( + + ({((1 - avgMilesPerDay / 6.6) * 100).toFixed(0)}% better!) + + )} +
+
+
+
+ ); +} diff --git a/components/analytics/SavingsCalculator.tsx b/components/analytics/SavingsCalculator.tsx new file mode 100644 index 0000000..612eb23 --- /dev/null +++ b/components/analytics/SavingsCalculator.tsx @@ -0,0 +1,273 @@ +import { useState, useMemo } from 'react'; + +interface SavingsCalculatorProps { + actualCarbon: number; + actualMiles: number; + produceWeightKg: number; + conventionalData?: ConventionalBaseline; +} + +export interface ConventionalBaseline { + avgCarbonPerKg: number; // Default: 2.5 kg CO2 per kg produce + avgMilesPerKg: number; // Default: 2400 km average food miles + avgWaterLitersPerKg: number; // Default: 500 liters per kg + avgFoodWastePercent: number; // Default: 30% waste +} + +const DEFAULT_CONVENTIONAL: ConventionalBaseline = { + avgCarbonPerKg: 2.5, + avgMilesPerKg: 2400, + avgWaterLitersPerKg: 500, + avgFoodWastePercent: 30, +}; + +const EQUIVALENTS = { + carbonPerTree: 21, // kg CO2 absorbed per tree per year + carbonPerCarMile: 0.404, // kg CO2 per mile driven + carbonPerFlight: 250, // kg CO2 per hour of flight + waterPerShower: 75, // liters per 5-min shower + carbonPerGallon: 8.89, // kg CO2 per gallon of gasoline +}; + +export default function SavingsCalculator({ + actualCarbon, + actualMiles, + produceWeightKg, + conventionalData = DEFAULT_CONVENTIONAL, +}: SavingsCalculatorProps) { + const [showDetails, setShowDetails] = useState(false); + + // Calculate conventional equivalents + const conventionalCarbon = produceWeightKg * conventionalData.avgCarbonPerKg; + const conventionalMiles = produceWeightKg * conventionalData.avgMilesPerKg; + const conventionalWater = produceWeightKg * conventionalData.avgWaterLitersPerKg; + const conventionalWaste = produceWeightKg * (conventionalData.avgFoodWastePercent / 100); + + // Calculate savings + const carbonSaved = conventionalCarbon - actualCarbon; + const milesSaved = conventionalMiles - actualMiles; + const wasteSaved = conventionalWaste * 0.75; // Assume 75% less waste with local + + // Calculate percentage reductions + const carbonReduction = conventionalCarbon > 0 ? (carbonSaved / conventionalCarbon) * 100 : 0; + const milesReduction = conventionalMiles > 0 ? (milesSaved / conventionalMiles) * 100 : 0; + + // Convert to tangible equivalents + const equivalents = useMemo(() => ({ + treesEquivalent: carbonSaved / EQUIVALENTS.carbonPerTree, + carMilesEquivalent: carbonSaved / EQUIVALENTS.carbonPerCarMile, + flightHoursEquivalent: carbonSaved / EQUIVALENTS.carbonPerFlight, + showersEquivalent: (conventionalWater - conventionalWater * 0.1) / EQUIVALENTS.waterPerShower, + gallonsGasoline: carbonSaved / EQUIVALENTS.carbonPerGallon, + }), [carbonSaved, conventionalWater]); + + // Calculate annual projections (assuming monthly data) + const annualMultiplier = 12; + const annualCarbonSaved = carbonSaved * annualMultiplier; + const annualMilesSaved = milesSaved * annualMultiplier; + + // Rating based on carbon reduction + const getRating = (reduction: number): { stars: number; label: string; color: string } => { + if (reduction >= 90) return { stars: 5, label: 'Outstanding', color: 'text-green-600' }; + if (reduction >= 75) return { stars: 4, label: 'Excellent', color: 'text-emerald-600' }; + if (reduction >= 50) return { stars: 3, label: 'Great', color: 'text-lime-600' }; + if (reduction >= 25) return { stars: 2, label: 'Good', color: 'text-yellow-600' }; + return { stars: 1, label: 'Getting Started', color: 'text-orange-600' }; + }; + + const rating = getRating(carbonReduction); + + return ( +
+ {/* Header */} +
+

Savings Calculator

+

See your environmental impact vs conventional

+
+ +
+ {/* Rating display */} +
+
+ {[1, 2, 3, 4, 5].map((star) => ( + + ⭐ + + ))} +
+

{rating.label}

+

+ You've reduced carbon by {carbonReduction.toFixed(0)}% compared to conventional! +

+
+ + {/* Main savings metrics */} +
+
+

{carbonSaved.toFixed(1)}

+

kg CO2 Saved

+
+
+
+

{carbonReduction.toFixed(0)}% reduction

+
+ +
+

{milesSaved.toFixed(0)}

+

km Saved

+
+
+
+

{milesReduction.toFixed(0)}% reduction

+
+
+ + {/* Comparison table */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricYour ImpactConventionalSaved
Carbon Footprint{actualCarbon.toFixed(2)} kg{conventionalCarbon.toFixed(2)} kg + -{carbonSaved.toFixed(2)} kg +
Food Miles{actualMiles.toFixed(0)} km{conventionalMiles.toFixed(0)} km + -{milesSaved.toFixed(0)} km +
Est. Food Waste + {(conventionalWaste * 0.25).toFixed(2)} kg + {conventionalWaste.toFixed(2)} kg + -{wasteSaved.toFixed(2)} kg +
+
+ + {/* Tangible equivalents */} +
+ + + {showDetails && ( +
+
+ 🌲 +

+ {equivalents.treesEquivalent.toFixed(1)} +

+

Trees planted (annual equivalent)

+
+ +
+ πŸš— +

+ {equivalents.carMilesEquivalent.toFixed(0)} +

+

Car miles avoided

+
+ +
+ ✈️ +

+ {equivalents.flightHoursEquivalent.toFixed(1)} +

+

Flight hours equivalent

+
+ +
+ 🚿 +

+ {equivalents.showersEquivalent.toFixed(0)} +

+

Showers worth of water

+
+ +
+ β›½ +

+ {equivalents.gallonsGasoline.toFixed(1)} +

+

Gallons of gas saved

+
+ +
+ 🌍 +

+ {carbonReduction.toFixed(0)}% +

+

Better for the planet

+
+
+ )} +
+ + {/* Annual projection */} +
+

Annual Projection

+
+
+

+ {annualCarbonSaved.toFixed(0)} kg CO2 +

+

Carbon saved per year

+
+
+

+ {annualMilesSaved.toFixed(0)} km +

+

Food miles saved per year

+
+
+

+ Based on current consumption of {produceWeightKg.toFixed(1)} kg produce +

+
+ + {/* Call to action */} +
+

+ Keep sourcing local produce to maximize your environmental impact! +

+

+ Every kilometer saved makes a difference +

+
+
+
+ ); +} diff --git a/components/analytics/index.ts b/components/analytics/index.ts new file mode 100644 index 0000000..6c36352 --- /dev/null +++ b/components/analytics/index.ts @@ -0,0 +1,5 @@ +export { default as EnvironmentalImpact } from './EnvironmentalImpact'; +export { default as FoodMilesTracker } from './FoodMilesTracker'; +export { default as SavingsCalculator } from './SavingsCalculator'; +export type { FoodMilesData } from './FoodMilesTracker'; +export type { ConventionalBaseline } from './SavingsCalculator'; diff --git a/components/demand/DemandSignalCard.tsx b/components/demand/DemandSignalCard.tsx new file mode 100644 index 0000000..5df114a --- /dev/null +++ b/components/demand/DemandSignalCard.tsx @@ -0,0 +1,163 @@ +import { DemandSignal, DemandItem } from '../../lib/demand/types'; + +interface DemandSignalCardProps { + signal: DemandSignal; + onViewDetails?: () => void; +} + +const STATUS_COLORS: Record = { + surplus: { bg: 'bg-green-100', text: 'text-green-800', icon: 'πŸ“ˆ' }, + balanced: { bg: 'bg-blue-100', text: 'text-blue-800', icon: 'βš–οΈ' }, + shortage: { bg: 'bg-yellow-100', text: 'text-yellow-800', icon: '⚠️' }, + critical: { bg: 'bg-red-100', text: 'text-red-800', icon: '🚨' }, +}; + +function formatCategory(category: string): string { + return category.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); +} + +export default function DemandSignalCard({ signal, onViewDetails }: DemandSignalCardProps) { + const statusStyle = STATUS_COLORS[signal.supplyStatus]; + + // Get top demand items + const topItems = [...signal.demandItems] + .sort((a, b) => b.weeklyDemandKg - a.weeklyDemandKg) + .slice(0, 5); + + const gapPercentage = signal.totalWeeklyDemandKg > 0 + ? (signal.supplyGapKg / signal.totalWeeklyDemandKg) * 100 + : 0; + + return ( +
+ {/* Header */} +
+
+
+

{signal.region.name}

+

+ {signal.seasonalPeriod.charAt(0).toUpperCase() + signal.seasonalPeriod.slice(1)} Season +

+
+
+ + {statusStyle.icon} {signal.supplyStatus.charAt(0).toUpperCase() + signal.supplyStatus.slice(1)} + +
+
+
+ +
+ {/* Key metrics */} +
+
+

{signal.totalConsumers}

+

Consumers

+
+
+

{signal.totalWeeklyDemandKg.toFixed(0)}

+

Weekly Demand (kg)

+
+
+

{signal.currentSupplyKg.toFixed(0)}

+

Current Supply (kg)

+
+
+ + {/* Supply Gap Indicator */} +
+
+ Supply Coverage + 20 ? 'text-red-600' : 'text-green-600'}`}> + {(100 - gapPercentage).toFixed(0)}% covered + +
+
+
50 ? 'bg-red-500' : gapPercentage > 20 ? 'bg-yellow-500' : 'bg-green-500' + }`} + style={{ width: `${Math.min(100 - gapPercentage, 100)}%` }} + >
+
+ {signal.supplyGapKg > 0 && ( +

+ Gap: {signal.supplyGapKg.toFixed(0)} kg needed +

+ )} +
+ + {/* Top Demand Items */} +
+

Top Demanded Items

+
+ {topItems.map((item, index) => ( + + ))} +
+
+ + {/* Confidence indicator */} +
+
+ Confidence: +
+ {[1, 2, 3, 4, 5].map((level) => ( +
+ ))} +
+ {signal.confidenceLevel}% +
+ + Updated: {new Date(signal.timestamp).toLocaleDateString()} + +
+ + {/* View Details Button */} + {onViewDetails && ( + + )} +
+
+ ); +} + +function DemandItemRow({ item, rank }: { item: DemandItem; rank: number }) { + const urgencyColors: Record = { + immediate: 'bg-red-100 text-red-700', + this_week: 'bg-orange-100 text-orange-700', + this_month: 'bg-yellow-100 text-yellow-700', + next_season: 'bg-blue-100 text-blue-700', + }; + + return ( +
+ + {rank} + +
+

{item.produceType}

+

{formatCategory(item.category)}

+
+
+

{item.weeklyDemandKg.toFixed(1)} kg

+ + {item.urgency.replace(/_/g, ' ')} + +
+ {item.inSeason && ( + 🌿 + )} +
+ ); +} diff --git a/components/demand/PreferencesForm.tsx b/components/demand/PreferencesForm.tsx new file mode 100644 index 0000000..0030294 --- /dev/null +++ b/components/demand/PreferencesForm.tsx @@ -0,0 +1,465 @@ +import { useState } from 'react'; +import { ConsumerPreference, ProduceCategory, ProducePreference } from '../../lib/demand/types'; + +interface PreferencesFormProps { + initialPreferences?: Partial; + onSubmit: (preferences: Partial) => void; + loading?: boolean; +} + +const DIETARY_TYPES = [ + { value: 'omnivore', label: 'Omnivore' }, + { value: 'vegetarian', label: 'Vegetarian' }, + { value: 'vegan', label: 'Vegan' }, + { value: 'pescatarian', label: 'Pescatarian' }, + { value: 'flexitarian', label: 'Flexitarian' }, +] as const; + +const PRODUCE_CATEGORIES: { value: ProduceCategory; label: string; icon: string }[] = [ + { value: 'leafy_greens', label: 'Leafy Greens', icon: 'πŸ₯¬' }, + { value: 'root_vegetables', label: 'Root Vegetables', icon: 'πŸ₯•' }, + { value: 'nightshades', label: 'Nightshades', icon: 'πŸ…' }, + { value: 'brassicas', label: 'Brassicas', icon: 'πŸ₯¦' }, + { value: 'alliums', label: 'Alliums', icon: 'πŸ§…' }, + { value: 'legumes', label: 'Legumes', icon: '🫘' }, + { value: 'squash', label: 'Squash', icon: 'πŸŽƒ' }, + { value: 'herbs', label: 'Herbs', icon: '🌿' }, + { value: 'microgreens', label: 'Microgreens', icon: '🌱' }, + { value: 'sprouts', label: 'Sprouts', icon: '🌾' }, + { value: 'mushrooms', label: 'Mushrooms', icon: 'πŸ„' }, + { value: 'fruits', label: 'Fruits', icon: '🍎' }, + { value: 'berries', label: 'Berries', icon: 'πŸ“' }, + { value: 'citrus', label: 'Citrus', icon: '🍊' }, + { value: 'tree_fruits', label: 'Tree Fruits', icon: 'πŸ‘' }, + { value: 'melons', label: 'Melons', icon: '🍈' }, + { value: 'edible_flowers', label: 'Edible Flowers', icon: '🌸' }, +]; + +const CERTIFICATIONS = [ + { value: 'organic', label: 'Organic' }, + { value: 'non_gmo', label: 'Non-GMO' }, + { value: 'biodynamic', label: 'Biodynamic' }, + { value: 'local', label: 'Local' }, + { value: 'heirloom', label: 'Heirloom' }, +] as const; + +const DELIVERY_METHODS = [ + { value: 'home_delivery', label: 'Home Delivery' }, + { value: 'pickup_point', label: 'Pickup Point' }, + { value: 'farmers_market', label: 'Farmers Market' }, + { value: 'csa', label: 'CSA Box' }, +] as const; + +const DELIVERY_FREQUENCIES = [ + { value: 'daily', label: 'Daily' }, + { value: 'twice_weekly', label: 'Twice Weekly' }, + { value: 'weekly', label: 'Weekly' }, + { value: 'bi_weekly', label: 'Bi-Weekly' }, + { value: 'monthly', label: 'Monthly' }, +] as const; + +export default function PreferencesForm({ + initialPreferences, + onSubmit, + loading = false, +}: PreferencesFormProps) { + const [activeSection, setActiveSection] = useState('dietary'); + + const [dietaryType, setDietaryType] = useState(initialPreferences?.dietaryType || ['omnivore']); + const [allergies, setAllergies] = useState(initialPreferences?.allergies?.join(', ') || ''); + const [dislikes, setDislikes] = useState(initialPreferences?.dislikes?.join(', ') || ''); + + const [preferredCategories, setPreferredCategories] = useState( + initialPreferences?.preferredCategories || [] + ); + const [certifications, setCertifications] = useState( + initialPreferences?.certificationPreferences || [] + ); + + const [freshnessImportance, setFreshnessImportance] = useState( + initialPreferences?.freshnessImportance || 4 + ); + const [priceImportance, setPriceImportance] = useState( + initialPreferences?.priceImportance || 3 + ); + const [sustainabilityImportance, setSustainabilityImportance] = useState( + initialPreferences?.sustainabilityImportance || 4 + ); + + const [deliveryMethods, setDeliveryMethods] = useState( + initialPreferences?.deliveryPreferences?.method || ['home_delivery'] + ); + const [deliveryFrequency, setDeliveryFrequency] = useState( + initialPreferences?.deliveryPreferences?.frequency || 'weekly' + ); + + const [householdSize, setHouseholdSize] = useState( + initialPreferences?.householdSize || 2 + ); + const [weeklyBudget, setWeeklyBudget] = useState( + initialPreferences?.weeklyBudget + ); + + const [maxDeliveryRadius, setMaxDeliveryRadius] = useState( + initialPreferences?.location?.maxDeliveryRadiusKm || 25 + ); + + const toggleCategory = (category: ProduceCategory) => { + setPreferredCategories((prev) => + prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category] + ); + }; + + const toggleCertification = (cert: string) => { + setCertifications((prev) => + prev.includes(cert) ? prev.filter((c) => c !== cert) : [...prev, cert] + ); + }; + + const toggleDeliveryMethod = (method: string) => { + setDeliveryMethods((prev) => + prev.includes(method) ? prev.filter((m) => m !== method) : [...prev, method] + ); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const preferences: Partial = { + dietaryType: dietaryType as ConsumerPreference['dietaryType'], + allergies: allergies.split(',').map((a) => a.trim()).filter(Boolean), + dislikes: dislikes.split(',').map((d) => d.trim()).filter(Boolean), + preferredCategories, + certificationPreferences: certifications as ConsumerPreference['certificationPreferences'], + freshnessImportance: freshnessImportance as 1 | 2 | 3 | 4 | 5, + priceImportance: priceImportance as 1 | 2 | 3 | 4 | 5, + sustainabilityImportance: sustainabilityImportance as 1 | 2 | 3 | 4 | 5, + deliveryPreferences: { + method: deliveryMethods as ConsumerPreference['deliveryPreferences']['method'], + frequency: deliveryFrequency as ConsumerPreference['deliveryPreferences']['frequency'], + preferredDays: ['saturday'], // Default + }, + householdSize, + weeklyBudget, + location: { + latitude: 0, + longitude: 0, + maxDeliveryRadiusKm: maxDeliveryRadius, + }, + }; + + onSubmit(preferences); + }; + + const sections = [ + { id: 'dietary', name: 'Diet', icon: 'πŸ₯—' }, + { id: 'produce', name: 'Produce', icon: 'πŸ₯¬' }, + { id: 'quality', name: 'Quality', icon: '⭐' }, + { id: 'delivery', name: 'Delivery', icon: 'πŸ“¦' }, + { id: 'household', name: 'Household', icon: '🏠' }, + ]; + + const ImportanceSlider = ({ + label, + value, + onChange, + helpText, + }: { + label: string; + value: number; + onChange: (value: number) => void; + helpText: string; + }) => ( +
+
+ + {value}/5 +
+ onChange(parseInt(e.target.value))} + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600" + /> +

{helpText}

+
+ ); + + return ( +
+ {/* Section Tabs */} +
+ {sections.map((section) => ( + + ))} +
+ +
+ {/* Dietary Section */} + {activeSection === 'dietary' && ( +
+

Dietary Preferences

+ +
+ +
+ {DIETARY_TYPES.map((type) => ( + + ))} +
+
+ +
+ + setAllergies(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500" + placeholder="e.g., nuts, shellfish, gluten" + /> +
+ +
+ + setDislikes(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500" + placeholder="e.g., cilantro, mushrooms" + /> +
+
+ )} + + {/* Produce Section */} + {activeSection === 'produce' && ( +
+

Produce Preferences

+ +
+ +
+ {PRODUCE_CATEGORIES.map((category) => ( + + ))} +
+
+
+ )} + + {/* Quality Section */} + {activeSection === 'quality' && ( +
+

Quality Preferences

+ +
+ +
+ {CERTIFICATIONS.map((cert) => ( + + ))} +
+
+ +
+ + + +
+
+ )} + + {/* Delivery Section */} + {activeSection === 'delivery' && ( +
+

Delivery Preferences

+ +
+ +
+ {DELIVERY_METHODS.map((method) => ( + + ))} +
+
+ +
+ + +
+ +
+ + setMaxDeliveryRadius(parseInt(e.target.value))} + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600" + /> +

+ Shorter radius = fresher produce, fewer food miles +

+
+
+ )} + + {/* Household Section */} + {activeSection === 'household' && ( +
+

Household Info

+ +
+ + setHouseholdSize(parseInt(e.target.value))} + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600" + /> +
+ +
+ +
+ $ + setWeeklyBudget(e.target.value ? parseInt(e.target.value) : undefined)} + className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500" + placeholder="e.g., 100" + /> +
+

+ Helps us recommend produce within your budget +

+
+
+ )} + + {/* Submit Button */} +
+ +
+
+
+ ); +} diff --git a/components/demand/RecommendationList.tsx b/components/demand/RecommendationList.tsx new file mode 100644 index 0000000..c1d0ecf --- /dev/null +++ b/components/demand/RecommendationList.tsx @@ -0,0 +1,206 @@ +import { PlantingRecommendation, RiskFactor } from '../../lib/demand/types'; + +interface RecommendationListProps { + recommendations: PlantingRecommendation[]; + onSelect?: (recommendation: PlantingRecommendation) => void; + loading?: boolean; +} + +const RISK_COLORS: Record = { + low: 'bg-green-100 text-green-800', + medium: 'bg-yellow-100 text-yellow-800', + high: 'bg-red-100 text-red-800', +}; + +const OVERALL_RISK_COLORS: Record = { + low: 'text-green-600 bg-green-50 border-green-200', + medium: 'text-yellow-600 bg-yellow-50 border-yellow-200', + high: 'text-red-600 bg-red-50 border-red-200', +}; + +function formatCategory(category: string): string { + return category.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); +} + +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +export default function RecommendationList({ + recommendations, + onSelect, + loading = false, +}: RecommendationListProps) { + if (loading) { + return ( +
+

Planting Recommendations

+
+ {[1, 2, 3].map((i) => ( +
+
+
+ ))} +
+
+ ); + } + + if (recommendations.length === 0) { + return ( +
+

Planting Recommendations

+
+
🌱
+

No recommendations available yet.

+

+ Check back later or update your preferences to get personalized recommendations. +

+
+
+ ); + } + + return ( +
+
+

Planting Recommendations

+ {recommendations.length} suggestions +
+ +
+ {recommendations.map((rec) => ( + + ))} +
+
+ ); +} + +function RecommendationCard({ + recommendation: rec, + onSelect, +}: { + recommendation: PlantingRecommendation; + onSelect?: (recommendation: PlantingRecommendation) => void; +}) { + return ( +
onSelect?.(rec)} + > + {/* Header */} +
+
+
+

{rec.produceType}

+

+ {rec.variety && {rec.variety}} + | + {formatCategory(rec.category)} +

+
+
+ {rec.overallRisk} Risk +
+
+
+ + {/* Content */} +
+ {/* Key metrics */} +
+
+

{rec.recommendedQuantity}

+

{rec.quantityUnit}

+
+
+

{rec.expectedYieldKg.toFixed(1)} kg

+

Expected Yield

+
+
+

${rec.projectedRevenue.toFixed(0)}

+

Est. Revenue

+
+
+ + {/* Timing */} +
+
+ Plant by: + {formatDate(rec.plantByDate)} +
+
|
+
+ Harvest: + + {formatDate(rec.expectedHarvestStart)} - {formatDate(rec.expectedHarvestEnd)} + +
+
+ + {/* Market opportunity */} +
+
+ Market Opportunity +
+ {[1, 2, 3, 4, 5].map((level) => ( +
+ ))} + {rec.marketConfidence}% +
+
+
+ Projected demand: + {rec.projectedDemandKg.toFixed(0)} kg + @ + ${rec.projectedPricePerKg.toFixed(2)}/kg +
+
+ + {/* Risk factors */} + {rec.riskFactors.length > 0 && ( +
+

Risk Factors

+
+ {rec.riskFactors.slice(0, 3).map((risk, index) => ( + + {risk.type} + + ))} + {rec.riskFactors.length > 3 && ( + + +{rec.riskFactors.length - 3} more + + )} +
+
+ )} + + {/* Explanation */} +

"{rec.explanation}"

+ + {/* Growing days indicator */} +
+ Growing period: {rec.growingDays} days + Yield confidence: {rec.yieldConfidence}% +
+
+
+ ); +} diff --git a/components/demand/SeasonalCalendar.tsx b/components/demand/SeasonalCalendar.tsx new file mode 100644 index 0000000..6c5102e --- /dev/null +++ b/components/demand/SeasonalCalendar.tsx @@ -0,0 +1,308 @@ +import { useState } from 'react'; +import { DemandItem, ProduceCategory } from '../../lib/demand/types'; + +interface SeasonalCalendarProps { + items: SeasonalItem[]; + currentSeason?: 'spring' | 'summer' | 'fall' | 'winter'; +} + +export interface SeasonalItem { + produceType: string; + category: ProduceCategory; + seasonalAvailability: { + spring: boolean; + summer: boolean; + fall: boolean; + winter: boolean; + }; + peakSeason?: 'spring' | 'summer' | 'fall' | 'winter'; +} + +const SEASONS = ['spring', 'summer', 'fall', 'winter'] as const; + +const SEASON_COLORS: Record = { + spring: { bg: 'bg-green-100', border: 'border-green-300', text: 'text-green-800', icon: '🌸' }, + summer: { bg: 'bg-yellow-100', border: 'border-yellow-300', text: 'text-yellow-800', icon: 'β˜€οΈ' }, + fall: { bg: 'bg-orange-100', border: 'border-orange-300', text: 'text-orange-800', icon: 'πŸ‚' }, + winter: { bg: 'bg-blue-100', border: 'border-blue-300', text: 'text-blue-800', icon: '❄️' }, +}; + +const CATEGORY_ICONS: Record = { + leafy_greens: 'πŸ₯¬', + root_vegetables: 'πŸ₯•', + nightshades: 'πŸ…', + brassicas: 'πŸ₯¦', + alliums: 'πŸ§…', + legumes: '🫘', + squash: 'πŸŽƒ', + herbs: '🌿', + microgreens: '🌱', + sprouts: '🌾', + mushrooms: 'πŸ„', + fruits: '🍎', + berries: 'πŸ“', + citrus: '🍊', + tree_fruits: 'πŸ‘', + melons: '🍈', + edible_flowers: '🌸', +}; + +function formatCategory(category: string): string { + return category.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); +} + +function getCurrentSeason(): 'spring' | 'summer' | 'fall' | 'winter' { + const month = new Date().getMonth(); + if (month >= 2 && month <= 4) return 'spring'; + if (month >= 5 && month <= 7) return 'summer'; + if (month >= 8 && month <= 10) return 'fall'; + return 'winter'; +} + +export default function SeasonalCalendar({ + items, + currentSeason = getCurrentSeason(), +}: SeasonalCalendarProps) { + const [selectedCategory, setSelectedCategory] = useState('all'); + const [viewMode, setViewMode] = useState<'calendar' | 'list'>('calendar'); + + // Get unique categories + const categories = Array.from(new Set(items.map((item) => item.category))); + + // Filter items by category + const filteredItems = selectedCategory === 'all' + ? items + : items.filter((item) => item.category === selectedCategory); + + // Group items by season for current view + const itemsBySeason: Record = { + spring: [], + summer: [], + fall: [], + winter: [], + }; + + filteredItems.forEach((item) => { + SEASONS.forEach((season) => { + if (item.seasonalAvailability[season]) { + itemsBySeason[season].push(item); + } + }); + }); + + // Get items available now + const availableNow = filteredItems.filter( + (item) => item.seasonalAvailability[currentSeason] + ); + + return ( +
+ {/* Header */} +
+

Seasonal Availability

+

+ Currently: {SEASON_COLORS[currentSeason].icon} {currentSeason.charAt(0).toUpperCase() + currentSeason.slice(1)} +

+
+ + {/* Controls */} +
+
+ {/* Category filter */} +
+ + +
+ + {/* View toggle */} +
+ + +
+
+
+ +
+ {/* Available Now Section */} +
+

+ {SEASON_COLORS[currentSeason].icon} + Available Now ({availableNow.length} items) +

+
+ {availableNow.slice(0, 12).map((item, index) => ( + + {CATEGORY_ICONS[item.category]} {item.produceType} + + ))} + {availableNow.length > 12 && ( + + +{availableNow.length - 12} more + + )} +
+
+ + {/* Calendar View */} + {viewMode === 'calendar' && ( +
+ {SEASONS.map((season) => { + const seasonStyle = SEASON_COLORS[season]; + const isCurrentSeason = season === currentSeason; + + return ( +
+
+
+ + {seasonStyle.icon} {season} + + + {itemsBySeason[season].length} items + +
+
+
+ {itemsBySeason[season].length === 0 ? ( +

+ No items in this season +

+ ) : ( +
+ {itemsBySeason[season].map((item, index) => ( +
+ {CATEGORY_ICONS[item.category]} + {item.produceType} + {item.peakSeason === season && ( + ⭐ + )} +
+ ))} +
+ )} +
+
+ ); + })} +
+ )} + + {/* List View */} + {viewMode === 'list' && ( +
+ + + + + + {SEASONS.map((season) => ( + + ))} + + + + {filteredItems.map((item, index) => ( + + + + {SEASONS.map((season) => ( + + ))} + + ))} + +
ItemCategory + {SEASON_COLORS[season].icon} +
+ {season} +
+ {CATEGORY_ICONS[item.category]} + {item.produceType} + + {formatCategory(item.category)} + + {item.seasonalAvailability[season] ? ( + + {item.peakSeason === season ? '⭐' : 'βœ“'} + + ) : ( + - + )} +
+
+ )} + + {/* Legend */} +
+
+ βœ“ + Available +
+
+ ⭐ + Peak Season +
+
+ - + Not Available +
+
+
+
+ ); +} diff --git a/components/demand/SupplyGapChart.tsx b/components/demand/SupplyGapChart.tsx new file mode 100644 index 0000000..d4e2301 --- /dev/null +++ b/components/demand/SupplyGapChart.tsx @@ -0,0 +1,187 @@ +import { DemandItem, DemandSignal } from '../../lib/demand/types'; + +interface SupplyGapChartProps { + demandSignal: DemandSignal; + showTopN?: number; +} + +function formatCategory(category: string): string { + return category.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); +} + +export default function SupplyGapChart({ demandSignal, showTopN = 10 }: SupplyGapChartProps) { + // Sort items by gap (largest gaps first) + const sortedItems = [...demandSignal.demandItems] + .filter((item) => item.gapKg > 0) + .sort((a, b) => b.gapKg - a.gapKg) + .slice(0, showTopN); + + // Find the max for scaling + const maxDemand = Math.max(...demandSignal.demandItems.map((item) => item.weeklyDemandKg)); + + // Calculate overall stats + const totalDemand = demandSignal.totalWeeklyDemandKg; + const totalSupply = demandSignal.currentSupplyKg; + const totalGap = demandSignal.supplyGapKg; + const coveragePercentage = totalDemand > 0 ? ((totalSupply / totalDemand) * 100) : 100; + + if (demandSignal.demandItems.length === 0) { + return ( +
+

Supply vs Demand

+
+
πŸ“Š
+

No demand data available.

+
+
+ ); + } + + return ( +
+

Supply vs Demand

+

{demandSignal.region.name} - {demandSignal.seasonalPeriod}

+ + {/* Overall summary */} +
+
+
+

{totalDemand.toFixed(0)}

+

Weekly Demand (kg)

+
+
+

{totalSupply.toFixed(0)}

+

Current Supply (kg)

+
+
+

0 ? 'text-red-600' : 'text-green-600'}`}> + {totalGap.toFixed(0)} +

+

Gap (kg)

+
+
+

= 100 ? 'text-green-600' : + coveragePercentage >= 80 ? 'text-yellow-600' : 'text-red-600' + }`}> + {coveragePercentage.toFixed(0)}% +

+

Coverage

+
+
+ + {/* Coverage bar */} +
+
+
= 100 ? 'bg-green-500' : + coveragePercentage >= 80 ? 'bg-yellow-500' : 'bg-red-500' + }`} + style={{ width: `${Math.min(coveragePercentage, 100)}%` }} + >
+
+
+
+ + {/* Gap items chart */} + {sortedItems.length > 0 && ( +
+

Largest Supply Gaps

+ {sortedItems.map((item, index) => ( + + ))} +
+ )} + + {/* Items with surplus */} + {demandSignal.demandItems.filter(item => item.gapKg <= 0).length > 0 && ( +
+

Well Supplied Items

+
+ {demandSignal.demandItems + .filter(item => item.gapKg <= 0) + .slice(0, 8) + .map((item, index) => ( + + {item.produceType} + + ))} +
+
+ )} + + {/* Legend */} +
+
+
+ Demand +
+
+
+ Supply +
+
+
+ Gap +
+
+
+ ); +} + +function SupplyGapBar({ item, maxDemand }: { item: DemandItem; maxDemand: number }) { + const demandWidth = (item.weeklyDemandKg / maxDemand) * 100; + const supplyWidth = (item.matchedSupply / maxDemand) * 100; + const gapPercentage = item.weeklyDemandKg > 0 + ? ((item.gapKg / item.weeklyDemandKg) * 100) + : 0; + + return ( +
+
+
+ {item.produceType} + {formatCategory(item.category)} +
+
+ 50 ? 'text-red-600' : 'text-yellow-600'}`}> + -{item.gapKg.toFixed(1)} kg + + ({gapPercentage.toFixed(0)}% gap) +
+
+ + {/* Stacked bar */} +
+ {/* Supply (filled portion) */} +
+ {/* Gap (unfilled portion shown as demand outline) */} +
+ {/* Gap indicator */} +
+
+ + {/* Values */} +
+ Supply: {item.matchedSupply.toFixed(0)} kg + Demand: {item.weeklyDemandKg.toFixed(0)} kg +
+
+ ); +} diff --git a/components/demand/index.ts b/components/demand/index.ts new file mode 100644 index 0000000..8dc15a5 --- /dev/null +++ b/components/demand/index.ts @@ -0,0 +1,6 @@ +export { default as DemandSignalCard } from './DemandSignalCard'; +export { default as PreferencesForm } from './PreferencesForm'; +export { default as RecommendationList } from './RecommendationList'; +export { default as SupplyGapChart } from './SupplyGapChart'; +export { default as SeasonalCalendar } from './SeasonalCalendar'; +export type { SeasonalItem } from './SeasonalCalendar'; diff --git a/components/transport/CarbonFootprintCard.tsx b/components/transport/CarbonFootprintCard.tsx new file mode 100644 index 0000000..f407139 --- /dev/null +++ b/components/transport/CarbonFootprintCard.tsx @@ -0,0 +1,191 @@ +import { EnvironmentalImpact, TransportMethod } from '../../lib/transport/types'; + +interface CarbonFootprintCardProps { + impact: EnvironmentalImpact; + showComparison?: boolean; +} + +const METHOD_LABELS: Record = { + walking: 'Walking', + bicycle: 'Bicycle', + electric_vehicle: 'Electric Vehicle', + hybrid_vehicle: 'Hybrid Vehicle', + gasoline_vehicle: 'Gas Vehicle', + diesel_truck: 'Diesel Truck', + electric_truck: 'Electric Truck', + refrigerated_truck: 'Refrigerated Truck', + rail: 'Rail', + ship: 'Ship', + air: 'Air Freight', + drone: 'Drone', + local_delivery: 'Local Delivery', + customer_pickup: 'Customer Pickup', +}; + +const METHOD_COLORS: Record = { + walking: 'bg-green-500', + bicycle: 'bg-green-400', + electric_vehicle: 'bg-emerald-400', + hybrid_vehicle: 'bg-lime-400', + electric_truck: 'bg-teal-400', + drone: 'bg-cyan-400', + rail: 'bg-blue-400', + ship: 'bg-blue-500', + local_delivery: 'bg-yellow-400', + customer_pickup: 'bg-orange-400', + gasoline_vehicle: 'bg-orange-500', + diesel_truck: 'bg-red-400', + refrigerated_truck: 'bg-red-500', + air: 'bg-red-600', +}; + +export default function CarbonFootprintCard({ impact, showComparison = true }: CarbonFootprintCardProps) { + // Calculate breakdown percentages + const totalCarbon = impact.totalCarbonKg; + const methodBreakdown = Object.entries(impact.breakdownByMethod) + .filter(([_, data]) => data.carbon > 0) + .sort((a, b) => b[1].carbon - a[1].carbon); + + const getCarbonRating = (carbon: number): { label: string; color: string; icon: string } => { + if (carbon < 0.5) return { label: 'Excellent', color: 'text-green-600', icon: '🌟' }; + if (carbon < 2) return { label: 'Good', color: 'text-lime-600', icon: 'βœ“' }; + if (carbon < 5) return { label: 'Moderate', color: 'text-yellow-600', icon: '⚑' }; + if (carbon < 10) return { label: 'High', color: 'text-orange-600', icon: '⚠️' }; + return { label: 'Very High', color: 'text-red-600', icon: '🚨' }; + }; + + const rating = getCarbonRating(totalCarbon); + + return ( +
+ {/* Header */} +
+
+
+

Carbon Footprint

+

Environmental impact analysis

+
+
+
{totalCarbon.toFixed(2)}
+
kg CO2e
+
+
+ + {/* Rating badge */} +
+ {rating.icon} + {rating.label} +
+
+ +
+ {/* Key metrics */} +
+
+

Total Food Miles

+

{impact.totalFoodMiles.toFixed(1)}

+

kilometers

+
+
+

Carbon Intensity

+

+ {impact.carbonPerKgProduce.toFixed(3)} +

+

kg CO2 / kg produce

+
+
+ + {/* Comparison with conventional */} + {showComparison && impact.comparisonToConventional && ( +
+

+ πŸ“Š + vs. Conventional Agriculture +

+ +
+
+
+ Carbon Saved + + {impact.comparisonToConventional.carbonSaved.toFixed(2)} kg + +
+
+
+
+
+ +
+
+ Miles Saved + + {impact.comparisonToConventional.milesSaved.toFixed(1)} km + +
+
+ +
+ + {impact.comparisonToConventional.percentageReduction.toFixed(0)}% + + reduction vs conventional +
+
+
+ )} + + {/* Breakdown by transport method */} + {methodBreakdown.length > 0 && ( +
+

Breakdown by Transport Method

+
+ {methodBreakdown.map(([method, data]) => { + const percentage = totalCarbon > 0 ? (data.carbon / totalCarbon) * 100 : 0; + return ( +
+
+ {METHOD_LABELS[method as TransportMethod] || method} +
+
+
+
+
+
+
+ {data.carbon.toFixed(3)} kg + ({percentage.toFixed(0)}%) +
+
+ ); + })} +
+
+ )} + + {/* Tips */} +
+

Tips to Reduce Impact

+
    + {totalCarbon > 5 && ( +
  • β€’ Consider electric or hybrid vehicles for transport
  • + )} + {impact.totalFoodMiles > 50 && ( +
  • β€’ Source produce from closer locations when possible
  • + )} +
  • β€’ Consolidate shipments to reduce trips
  • +
  • β€’ Use bicycle delivery for short distances
  • +
+
+
+
+ ); +} diff --git a/components/transport/JourneyMap.tsx b/components/transport/JourneyMap.tsx new file mode 100644 index 0000000..17caf9c --- /dev/null +++ b/components/transport/JourneyMap.tsx @@ -0,0 +1,231 @@ +import { useState } from 'react'; +import { TransportEvent, TransportLocation } from '../../lib/transport/types'; + +interface JourneyMapProps { + plantId: string; + events: TransportEvent[]; + currentLocation?: TransportLocation; +} + +interface MapPoint { + lat: number; + lng: number; + label: string; + eventType: string; + timestamp: string; +} + +export default function JourneyMap({ plantId, events, currentLocation }: JourneyMapProps) { + const [selectedPoint, setSelectedPoint] = useState(null); + + // Extract unique locations from events + const mapPoints: MapPoint[] = []; + const seenCoords = new Set(); + + events.forEach((event) => { + const fromKey = `${event.fromLocation.latitude},${event.fromLocation.longitude}`; + const toKey = `${event.toLocation.latitude},${event.toLocation.longitude}`; + + if (!seenCoords.has(fromKey)) { + seenCoords.add(fromKey); + mapPoints.push({ + lat: event.fromLocation.latitude, + lng: event.fromLocation.longitude, + label: event.fromLocation.facilityName || event.fromLocation.city || 'Origin', + eventType: event.eventType, + timestamp: event.timestamp, + }); + } + + if (!seenCoords.has(toKey)) { + seenCoords.add(toKey); + mapPoints.push({ + lat: event.toLocation.latitude, + lng: event.toLocation.longitude, + label: event.toLocation.facilityName || event.toLocation.city || 'Destination', + eventType: event.eventType, + timestamp: event.timestamp, + }); + } + }); + + // Calculate map bounds + const lats = mapPoints.map((p) => p.lat); + const lngs = mapPoints.map((p) => p.lng); + const minLat = Math.min(...lats); + const maxLat = Math.max(...lats); + const minLng = Math.min(...lngs); + const maxLng = Math.max(...lngs); + + // Calculate center and scale for SVG + const centerLat = (minLat + maxLat) / 2; + const centerLng = (minLng + maxLng) / 2; + const latRange = Math.max(maxLat - minLat, 0.01); + const lngRange = Math.max(maxLng - minLng, 0.01); + + // Convert geo coords to SVG coords + const toSvgCoords = (lat: number, lng: number) => { + const x = ((lng - minLng) / lngRange) * 280 + 60; + const y = ((maxLat - lat) / latRange) * 180 + 60; + return { x, y }; + }; + + // Calculate total distance + const totalDistance = events.reduce((sum, e) => sum + e.distanceKm, 0); + + if (events.length === 0) { + return ( +
+

Journey Map

+
+
πŸ—ΊοΈ
+

No journey data available yet.

+

Transport events will appear here as they're recorded.

+
+
+ ); + } + + return ( +
+
+

Journey Map

+ {mapPoints.length} locations +
+ + {/* SVG Map */} +
+ + {/* Grid lines */} + + + + + + + + {/* Draw paths between consecutive events */} + {events.map((event, index) => { + const from = toSvgCoords(event.fromLocation.latitude, event.fromLocation.longitude); + const to = toSvgCoords(event.toLocation.latitude, event.toLocation.longitude); + return ( + + + {/* Arrow head */} + + + ); + })} + + {/* Draw location points */} + {mapPoints.map((point, index) => { + const { x, y } = toSvgCoords(point.lat, point.lng); + const isSelected = selectedPoint?.lat === point.lat && selectedPoint?.lng === point.lng; + return ( + setSelectedPoint(point)} + className="cursor-pointer" + > + + + {index + 1} + + + ); + })} + + {/* Current location marker */} + {currentLocation && ( + + {(() => { + const { x, y } = toSvgCoords(currentLocation.latitude, currentLocation.longitude); + return ( + <> + + + + ); + })()} + + )} + + + {/* Legend */} +
+
+ + Origin +
+
+ + Waypoint +
+
+ + Current +
+
+
+ + {/* Selected point details */} + {selectedPoint && ( +
+
+

{selectedPoint.label}

+ +
+

+ Event: {selectedPoint.eventType.replace(/_/g, ' ')} +

+

+ {new Date(selectedPoint.timestamp).toLocaleString()} +

+

+ Coords: {selectedPoint.lat.toFixed(4)}, {selectedPoint.lng.toFixed(4)} +

+
+ )} + + {/* Summary stats */} +
+
+

{totalDistance.toFixed(1)} km

+

Total Distance

+
+
+

{mapPoints.length}

+

Locations

+
+
+

{events.length}

+

Transports

+
+
+
+ ); +} diff --git a/components/transport/QRCodeDisplay.tsx b/components/transport/QRCodeDisplay.tsx new file mode 100644 index 0000000..93969a8 --- /dev/null +++ b/components/transport/QRCodeDisplay.tsx @@ -0,0 +1,217 @@ +import { useState } from 'react'; +import { TransportQRData } from '../../lib/transport/types'; + +interface QRCodeDisplayProps { + qrData: TransportQRData; + size?: number; + showDetails?: boolean; +} + +// Simple QR code matrix generator (basic implementation) +function generateQRMatrix(data: string, size: number = 21): boolean[][] { + // This is a simplified representation - in production you'd use a library like 'qrcode' + const matrix: boolean[][] = Array(size) + .fill(null) + .map(() => Array(size).fill(false)); + + // Add finder patterns (corners) + const addFinderPattern = (row: number, col: number) => { + for (let r = 0; r < 7; r++) { + for (let c = 0; c < 7; c++) { + if (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4)) { + if (row + r < size && col + c < size) { + matrix[row + r][col + c] = true; + } + } + } + } + }; + + addFinderPattern(0, 0); + addFinderPattern(0, size - 7); + addFinderPattern(size - 7, 0); + + // Add some data-based pattern + const hash = data.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + for (let i = 8; i < size - 8; i++) { + for (let j = 8; j < size - 8; j++) { + matrix[i][j] = ((i * j + hash) % 3) === 0; + } + } + + return matrix; +} + +export default function QRCodeDisplay({ qrData, size = 200, showDetails = true }: QRCodeDisplayProps) { + const [copied, setCopied] = useState(false); + const [downloading, setDownloading] = useState(false); + + const qrString = JSON.stringify(qrData); + const matrix = generateQRMatrix(qrString); + const cellSize = size / matrix.length; + + const handleCopyLink = async () => { + try { + await navigator.clipboard.writeText(qrData.quickLookupUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + const handleDownload = () => { + setDownloading(true); + + // Create SVG for download + const svgContent = ` + + + ${matrix + .map((row, i) => + row + .map((cell, j) => + cell + ? `` + : '' + ) + .join('') + ) + .join('')} + + `; + + const blob = new Blob([svgContent], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `qr-${qrData.plantId || qrData.batchId || 'code'}.svg`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + setDownloading(false); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + return ( +
+ {/* Header */} +
+

Traceability QR Code

+

Scan to verify authenticity

+
+ +
+ {/* QR Code */} +
+
+ + + {matrix.map((row, i) => + row.map((cell, j) => + cell ? ( + + ) : null + ) + )} + +
+
+ + {/* Action buttons */} +
+ + +
+ + {/* Details */} + {showDetails && ( +
+ {qrData.plantId && ( +
+ Plant ID + {qrData.plantId.slice(0, 12)}... +
+ )} + {qrData.batchId && ( +
+ Batch ID + {qrData.batchId.slice(0, 12)}... +
+ )} +
+ Last Event + + {qrData.lastEventType.replace(/_/g, ' ')} + +
+
+ Last Updated + {formatDate(qrData.lastEventTimestamp)} +
+
+ Current Custodian + {qrData.currentCustodian} +
+
+ Verification Code + {qrData.verificationCode} +
+
+ )} + + {/* Blockchain info */} +
+
+ πŸ”— + Blockchain Verified +
+

+ {qrData.blockchainAddress} +

+
+
+
+ ); +} diff --git a/components/transport/TransportEventForm.tsx b/components/transport/TransportEventForm.tsx new file mode 100644 index 0000000..6e6d5cf --- /dev/null +++ b/components/transport/TransportEventForm.tsx @@ -0,0 +1,324 @@ +import { useState } from 'react'; +import { TransportEventType, TransportMethod, TransportLocation } from '../../lib/transport/types'; + +interface TransportEventFormProps { + onSubmit: (data: TransportEventFormData) => void; + plantId?: string; + batchId?: string; + loading?: boolean; + defaultEventType?: TransportEventType; +} + +export interface TransportEventFormData { + eventType: TransportEventType; + fromLocation: Partial; + toLocation: Partial; + transportMethod: TransportMethod; + notes?: string; + plantIds?: string[]; + seedBatchId?: string; +} + +const EVENT_TYPES: { value: TransportEventType; label: string; icon: string }[] = [ + { value: 'seed_acquisition', label: 'Seed Acquisition', icon: '🌱' }, + { value: 'planting', label: 'Planting', icon: '🌿' }, + { value: 'growing_transport', label: 'Growing Transport', icon: '🚚' }, + { value: 'harvest', label: 'Harvest', icon: 'πŸ₯¬' }, + { value: 'processing', label: 'Processing', icon: 'βš™οΈ' }, + { value: 'distribution', label: 'Distribution', icon: 'πŸ“¦' }, + { value: 'consumer_delivery', label: 'Consumer Delivery', icon: '🏠' }, + { value: 'seed_saving', label: 'Seed Saving', icon: 'πŸ’Ύ' }, + { value: 'seed_sharing', label: 'Seed Sharing', icon: '🀝' }, +]; + +const TRANSPORT_METHODS: { value: TransportMethod; label: string; carbonInfo: string }[] = [ + { value: 'walking', label: 'Walking', carbonInfo: '0 kg CO2/km' }, + { value: 'bicycle', label: 'Bicycle', carbonInfo: '0 kg CO2/km' }, + { value: 'electric_vehicle', label: 'Electric Vehicle', carbonInfo: '0.02 kg CO2/km' }, + { value: 'hybrid_vehicle', label: 'Hybrid Vehicle', carbonInfo: '0.08 kg CO2/km' }, + { value: 'gasoline_vehicle', label: 'Gasoline Vehicle', carbonInfo: '0.12 kg CO2/km' }, + { value: 'diesel_truck', label: 'Diesel Truck', carbonInfo: '0.15 kg CO2/km' }, + { value: 'electric_truck', label: 'Electric Truck', carbonInfo: '0.03 kg CO2/km' }, + { value: 'refrigerated_truck', label: 'Refrigerated Truck', carbonInfo: '0.25 kg CO2/km' }, + { value: 'rail', label: 'Rail', carbonInfo: '0.01 kg CO2/km' }, + { value: 'ship', label: 'Ship', carbonInfo: '0.008 kg CO2/km' }, + { value: 'air', label: 'Air Freight', carbonInfo: '0.5 kg CO2/km' }, + { value: 'drone', label: 'Drone', carbonInfo: '0.01 kg CO2/km' }, + { value: 'local_delivery', label: 'Local Delivery', carbonInfo: '0.05 kg CO2/km' }, + { value: 'customer_pickup', label: 'Customer Pickup', carbonInfo: '0.1 kg CO2/km' }, +]; + +const LOCATION_TYPES = [ + 'farm', + 'greenhouse', + 'vertical_farm', + 'warehouse', + 'hub', + 'market', + 'consumer', + 'seed_bank', + 'other', +] as const; + +export default function TransportEventForm({ + onSubmit, + plantId, + batchId, + loading = false, + defaultEventType = 'harvest', +}: TransportEventFormProps) { + const [eventType, setEventType] = useState(defaultEventType); + const [transportMethod, setTransportMethod] = useState('electric_vehicle'); + const [notes, setNotes] = useState(''); + const [useCurrentLocation, setUseCurrentLocation] = useState(false); + const [gettingLocation, setGettingLocation] = useState(false); + + const [fromLocation, setFromLocation] = useState>({ + latitude: 0, + longitude: 0, + locationType: 'farm', + city: '', + region: '', + }); + + const [toLocation, setToLocation] = useState>({ + latitude: 0, + longitude: 0, + locationType: 'market', + city: '', + region: '', + }); + + const getCurrentLocation = async (target: 'from' | 'to') => { + if (!navigator.geolocation) { + alert('Geolocation is not supported by your browser'); + return; + } + + setGettingLocation(true); + + navigator.geolocation.getCurrentPosition( + (position) => { + const update = { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }; + + if (target === 'from') { + setFromLocation((prev) => ({ ...prev, ...update })); + } else { + setToLocation((prev) => ({ ...prev, ...update })); + } + setGettingLocation(false); + }, + (error) => { + console.error('Error getting location:', error); + alert('Unable to get your location. Please enter coordinates manually.'); + setGettingLocation(false); + } + ); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + onSubmit({ + eventType, + fromLocation, + toLocation, + transportMethod, + notes: notes || undefined, + plantIds: plantId ? [plantId] : undefined, + seedBatchId: batchId, + }); + }; + + const LocationInput = ({ + label, + value, + onChange, + onGetCurrent, + }: { + label: string; + value: Partial; + onChange: (loc: Partial) => void; + onGetCurrent: () => void; + }) => ( +
+
+

{label}

+ +
+ +
+
+ + onChange({ ...value, latitude: parseFloat(e.target.value) || 0 })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500" + placeholder="0.0000" + /> +
+
+ + onChange({ ...value, longitude: parseFloat(e.target.value) || 0 })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500" + placeholder="0.0000" + /> +
+
+ +
+
+ + onChange({ ...value, city: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500" + placeholder="City name" + /> +
+
+ + onChange({ ...value, region: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500" + placeholder="State/Province" + /> +
+
+ +
+ + +
+
+ ); + + return ( +
+

Record Transport Event

+ + {/* Event Type */} +
+ +
+ {EVENT_TYPES.map((type) => ( + + ))} +
+
+ + {/* Locations */} +
+ getCurrentLocation('from')} + /> + getCurrentLocation('to')} + /> +
+ + {/* Transport Method */} +
+ + +

+ Carbon emissions are calculated based on distance and transport method +

+
+ + {/* Notes */} +
+ +