From fdc9684dbf14bdfd1a5f89e9063add9d0c6b5064 Mon Sep 17 00:00:00 2001 From: Shade555 Date: Sat, 6 Jun 2026 01:38:03 +0530 Subject: [PATCH 1/9] fix: use venv uvicorn in dev:api script for Windows compatibility --- frontend/package-lock.json | 13 ++++ frontend/package.json | 1 + package-lock.json | 139 ++++++++++++++++++++++++++++++++++++- package.json | 5 +- 4 files changed, 153 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3458faf..71d55ed 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@turf/turf": "^7.3.4", "@types/leaflet": "^1.9.20", "@types/turf": "^3.5.32", + "Agronavis": "file:..", "axios": "^1.6.2", "dotenv": "^17.4.2", "i18next": "^25.6.0", @@ -52,6 +53,14 @@ "ts-node": "^10.9.2" } }, + "..": { + "name": "Agronavis", + "version": "2.0.0", + "devDependencies": { + "concurrently": "^8.2.2", + "supabase": "^2.105.0" + } + }, "node_modules/@adobe/css-tools": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", @@ -6928,6 +6937,10 @@ "node": ">= 14" } }, + "node_modules/Agronavis": { + "resolved": "..", + "link": true + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index fe58917..d471401 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@turf/turf": "^7.3.4", "@types/leaflet": "^1.9.20", "@types/turf": "^3.5.32", + "Agronavis": "file:..", "axios": "^1.6.2", "dotenv": "^17.4.2", "i18next": "^25.6.0", diff --git a/package-lock.json b/package-lock.json index 579d6d2..591c243 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { - "name": "farm-assistant-app", + "name": "Agronavis", "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "farm-assistant-app", + "name": "Agronavis", "version": "2.0.0", "devDependencies": { - "concurrently": "^8.2.2" + "concurrently": "^8.2.2", + "supabase": "^2.105.0" } }, "node_modules/@babel/runtime": { @@ -21,6 +22,118 @@ "node": ">=6.9.0" } }, + "node_modules/@supabase/cli-darwin-arm64": { + "version": "2.105.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.105.0.tgz", + "integrity": "sha512-ptlLrggNCq7dndvY7ce0MIvCZRnAYXeyKC0H7c4DqQmCbOPZgSwD5a4E3RPrZS6TLeJPG7XhpuJarAU5PTf/9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-darwin-x64": { + "version": "2.105.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.105.0.tgz", + "integrity": "sha512-kJDYyy3UkXd4hDzo+duOzUk8yDqLEit8d/clBCiXNQFSJf96QCnG2iyrzT4dCDUk8UB3+g9xL1mH2EEuPHj7oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-linux-arm64": { + "version": "2.105.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.105.0.tgz", + "integrity": "sha512-cdHgfIFElYkAjrp0aJPlQPTqQ9doSxB4qRswlGNsSYaLlK5Ns78rXsB7G71RRnyrzb41C183osRhVENWVrnK/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-arm64-musl": { + "version": "2.105.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.105.0.tgz", + "integrity": "sha512-45vxbXRwe/JUAjyvQ8oKO2o44IK3GunlVqeIWGzcDgaeMg5XO4x0i9+NGPvOvvhyMB4bgJ3cMxh/ovl1AM0GZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64": { + "version": "2.105.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.105.0.tgz", + "integrity": "sha512-rj1iU0h4EJaanx72eJtipiXczAY3gug9qxo62MYUZg2X1aaegFtyMS83fmjC7YlduW2Cu+zvgWoyTlA6R5Ndzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64-musl": { + "version": "2.105.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.105.0.tgz", + "integrity": "sha512-VnHn1NPn9Ov81BXFOx6FpHxbBT5e3ObaUxO7DR+LDGjh2wfMUj4E6Y863w+r0HJk0hi0mRH7u0U0dOrbBp4E+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-windows-arm64": { + "version": "2.105.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.105.0.tgz", + "integrity": "sha512-b5xQ5dVkARZTBiSwLpPbwffRHvVDcO3ZvAtzz54J44KQdLD0TSrgoXZJBrI4Y3ggmG70EiITKPL8+rT6ptjXsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/cli-windows-x64": { + "version": "2.105.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.105.0.tgz", + "integrity": "sha512-QmfqDQJ58ba/9+vzM8HJQsa/rY6OdtaaaAmPKwCMd8JUQ7758p8sydYe1vXTb4CCPdvk9qW2d/Dh54ucH4SPwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -278,6 +391,26 @@ "node": ">=8" } }, + "node_modules/supabase": { + "version": "2.105.0", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.105.0.tgz", + "integrity": "sha512-UB2aFLYAVujTQsZ9l+aCbDfLNaZApZucByRNP/1j0L1pXXzFhSgEyZSrvHSUO5LIvOb09AGHWishL/usVTuHTg==", + "dev": true, + "license": "MIT", + "bin": { + "supabase": "dist/supabase.js" + }, + "optionalDependencies": { + "@supabase/cli-darwin-arm64": "2.105.0", + "@supabase/cli-darwin-x64": "2.105.0", + "@supabase/cli-linux-arm64": "2.105.0", + "@supabase/cli-linux-arm64-musl": "2.105.0", + "@supabase/cli-linux-x64": "2.105.0", + "@supabase/cli-linux-x64-musl": "2.105.0", + "@supabase/cli-windows-arm64": "2.105.0", + "@supabase/cli-windows-x64": "2.105.0" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", diff --git a/package.json b/package.json index f62defc..56fb5c2 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,14 @@ "private": true, "scripts": { "dev": "concurrently --names \"API,WEB\" --prefix-colors \"green,cyan\" \"npm run dev:api\" \"npm run dev:web\"", - "dev:api": "cd backend && uvicorn main:app --reload --port 8000", + "dev:api": "backend\\venv\\Scripts\\uvicorn.exe main:app --reload --port 8000 --app-dir backend", "dev:web": "cd frontend && npm run dev", "install:frontend": "npm install --prefix frontend", "build": "npm run build --prefix frontend", "lint": "npm run lint --prefix frontend" }, "devDependencies": { - "concurrently": "^8.2.2" + "concurrently": "^8.2.2", + "supabase": "^2.105.0" } } From 420b17e7139842fd0640db145717946f877cfc65 Mon Sep 17 00:00:00 2001 From: Shade555 Date: Sat, 6 Jun 2026 01:38:30 +0530 Subject: [PATCH 2/9] fix: guard model load against empty .pth file and replace emoji in print statements --- backend/main.py | 29 ++++++++------- backend/supabase/seed.sql | 76 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 backend/supabase/seed.sql diff --git a/backend/main.py b/backend/main.py index 8a2fb59..1822a7e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -118,13 +118,16 @@ def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) resnet_model.fc = nn.Linear(resnet_model.fc.in_features, NUM_CLASSES) MODEL_PATH = os.path.join(BASE_DIR, "model", "plant_disease_resnet18.pth") -if os.path.exists(MODEL_PATH): - resnet_model.load_state_dict(torch.load(MODEL_PATH, map_location=device)) - resnet_model = resnet_model.to(device) - resnet_model.eval() - print(f"✅ ResNet18 loaded — {NUM_CLASSES} classes on {device}") +if os.path.exists(MODEL_PATH) and os.path.getsize(MODEL_PATH) > 0: + try: + resnet_model.load_state_dict(torch.load(MODEL_PATH, map_location=device)) + resnet_model = resnet_model.to(device) + resnet_model.eval() + print(f"[OK] ResNet18 loaded -- {NUM_CLASSES} classes on {device}") + except Exception as e: + print(f"[WARN] Failed to load model weights: {e}. Inference will fail.") else: - print(f"⚠️ Model weights not found at {MODEL_PATH}. Inference will fail.") + print(f"[WARN] Model weights not found or empty at {MODEL_PATH}. Inference will fail.") # CLIP model (OOD guard) — lazy load to keep startup fast clip_model = None @@ -136,12 +139,12 @@ def load_clip(): return try: from transformers import CLIPModel, CLIPProcessor - print("Loading CLIP (OOD guard)…") + print("Loading CLIP (OOD guard)...") clip_model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32").to(device) clip_processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32") - print("✅ CLIP loaded") + print("[OK] CLIP loaded") except Exception as e: - print(f"⚠️ CLIP unavailable: {e}") + print(f"[WARN] CLIP unavailable: {e}") # ── Pydantic schemas ───────────────────────────────────────────────────────── @@ -628,7 +631,7 @@ async def get_fields(farm_id: str, user=Depends(verify_token)): res = supabase.table("farms").select("location").eq("id", farm_id).eq("farmer_id", user.id).limit(1).execute() if not res.data: raise HTTPException(status_code=404, detail="Farm not found") - fields = (res.data.get("location") or {}).get("fields", []) + fields = (res.data[0].get("location") or {}).get("fields", []) return {"success": True, "data": fields} @app.post("/api/farms/{farm_id}/fields", status_code=201) @@ -637,7 +640,7 @@ async def add_field(farm_id: str, body: FieldCreate, user=Depends(verify_token)) if not owned.data: raise HTTPException(status_code=403, detail="Access denied") - current_loc = owned.data[0][0].get("location") or {} + current_loc = (owned.data[0].get("location") or {}) current_fields = current_loc.get("fields", []) new_field = { @@ -660,10 +663,10 @@ async def delete_field(farm_id: str, field_id: str, user=Depends(verify_token)): if not owned.data: raise HTTPException(status_code=403, detail="Access denied") - current_loc = owned.data[0][0].get("location") or {} + current_loc = (owned.data[0].get("location") or {}) current_fields = current_loc.get("fields", []) updated_fields = [f for f in current_fields if f.get("id") != field_id] - total_area = sum(f.get("area_acres", 0) for f in updated_fields) or owned.data[0][0].get("total_area", 0) + total_area = sum(f.get("area_acres", 0) for f in updated_fields) or owned.data[0].get("total_area", 0) supabase.table("farms").update({ "location": {**current_loc, "fields": updated_fields}, diff --git a/backend/supabase/seed.sql b/backend/supabase/seed.sql new file mode 100644 index 0000000..b6f2091 --- /dev/null +++ b/backend/supabase/seed.sql @@ -0,0 +1,76 @@ +-- AgroNavis local development seed +-- Creates the dev bypass user referenced in .env.example +-- Runs automatically on: npx supabase db reset +-- +-- Credentials: dev@agronavis.local / password123 +-- Matches NEXT_PUBLIC_DEV_EMAIL / NEXT_PUBLIC_DEV_PASSWORD in .env +-- DO NOT add real credentials or production data here. + +DO $$ +DECLARE + dev_user_id uuid := gen_random_uuid(); +BEGIN + -- Only insert if the dev user doesn't already exist + IF NOT EXISTS ( + SELECT 1 FROM auth.users WHERE email = 'dev@agronavis.local' + ) THEN + INSERT INTO auth.users ( + id, + instance_id, + aud, + role, + email, + encrypted_password, + email_confirmed_at, + raw_app_meta_data, + raw_user_meta_data, + created_at, + updated_at, + confirmation_token, + recovery_token, + email_change_token_new, + email_change + ) VALUES ( + dev_user_id, + '00000000-0000-0000-0000-000000000000', + 'authenticated', + 'authenticated', + 'dev@agronavis.local', + -- bcrypt hash of 'password123' + crypt('password123', gen_salt('bf')), + now(), + '{"provider":"email","providers":["email"]}', + '{"email_verified":true}', + now(), + now(), + '', + '', + '', + '' + ); + + INSERT INTO auth.identities ( + id, + user_id, + identity_data, + provider, + provider_id, + last_sign_in_at, + created_at, + updated_at + ) VALUES ( + gen_random_uuid(), + dev_user_id, + json_build_object('sub', dev_user_id::text, 'email', 'dev@agronavis.local'), + 'email', + dev_user_id::text, + now(), + now(), + now() + ); + + RAISE NOTICE 'Dev user created: dev@agronavis.local'; + ELSE + RAISE NOTICE 'Dev user already exists, skipping.'; + END IF; +END $$; From c3caf9bf394aa3eff393591c8f61f66f652a60ef Mon Sep 17 00:00:00 2001 From: Shade555 Date: Sat, 6 Jun 2026 01:44:00 +0530 Subject: [PATCH 3/9] feat: add field name input to PolygonMapper - Added a required 'Field Name' text input above the map - Validation prevents confirming a polygon without a name - onPolygonComplete callback now includes fieldName in the payload - Map instructions updated to reflect the new flow - Component resets name and polygon after each confirmed field so multiple fields can be drawn back-to-back without a page reload --- frontend/src/components/map/PolygonMapper.tsx | 133 ++++++++++++------ 1 file changed, 88 insertions(+), 45 deletions(-) diff --git a/frontend/src/components/map/PolygonMapper.tsx b/frontend/src/components/map/PolygonMapper.tsx index 60ca18c..24cfaae 100644 --- a/frontend/src/components/map/PolygonMapper.tsx +++ b/frontend/src/components/map/PolygonMapper.tsx @@ -1,13 +1,12 @@ -'use client'; // if you are using Next.js App Router +'use client'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import dynamic from 'next/dynamic'; import { usePolygonArea } from '../../hooks/usePolygonArea'; import SearchBox from './SearchBox'; import type { LatLng } from '../../utils/geoUtils'; // Leaflet must be loaded client-side only (it accesses window) -// So we lazy-load the inner map component const MapInner = dynamic(() => import('./MapInner'), { ssr: false, loading: () => ( @@ -21,39 +20,43 @@ const MapInner = dynamic(() => import('./MapInner'), { ), }); +interface PolygonCompleteData { + fieldName: string; + coordinates: LatLng[]; + areaAcres: number; + areaHectares: number; + centerLat: number; + centerLng: number; +} + interface PolygonMapperProps { - onPolygonComplete: (data: { - coordinates: LatLng[]; - areaAcres: number; - areaHectares: number; - centerLat: number; - centerLng: number; - }) => void; + onPolygonComplete: (data: PolygonCompleteData) => void; initialCenter?: LatLng; showInstructions?: boolean; } -export default function PolygonMapper({ onPolygonComplete, initialCenter, showInstructions = true }: PolygonMapperProps) { +export default function PolygonMapper({ + onPolygonComplete, + initialCenter, + showInstructions = true, +}: PolygonMapperProps) { const { points, area, center, isComplete, addPoint, removeLastPoint, resetPolygon } = usePolygonArea(); const [mapCenter, setMapCenter] = useState(initialCenter || { lat: 22.5726, lng: 88.3639 }); const [mapZoom, setMapZoom] = useState(13); const [locationLoading, setLocationLoading] = useState(false); + const [fieldName, setFieldName] = useState(''); + const [nameError, setNameError] = useState(''); useEffect(() => { if (!initialCenter && navigator.geolocation) { setLocationLoading(true); navigator.geolocation.getCurrentPosition( (position) => { - setMapCenter({ - lat: position.coords.latitude, - lng: position.coords.longitude - }); + setMapCenter({ lat: position.coords.latitude, lng: position.coords.longitude }); setMapZoom(15); setLocationLoading(false); }, - () => { - setLocationLoading(false); - } + () => { setLocationLoading(false); } ); } else if (initialCenter) { setMapCenter(initialCenter); @@ -68,13 +71,23 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn const handleConfirm = () => { if (!isComplete) return; + const trimmed = fieldName.trim(); + if (!trimmed) { + setNameError('Give this field a name before saving.'); + return; + } + setNameError(''); onPolygonComplete({ + fieldName: trimmed, coordinates: points, areaAcres: area.acres, areaHectares: area.hectares, centerLat: center.lat, centerLng: center.lng, }); + // Reset for the next field + resetPolygon(); + setFieldName(''); }; return ( @@ -83,14 +96,50 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn {/* Header */}

- Draw your field boundary + Draw a field boundary

- Click 4 corners to mark your field boundary. + Name the field, then click 3+ corners on the map to draw its boundary.

- {/* Search bar (floats above the map visually) */} + {/* Field name input */} +
+ + { setFieldName(e.target.value); if (nameError) setNameError(''); }} + placeholder="e.g. Wheat Field, North Paddy, Block A" + maxLength={60} + style={{ + width: '100%', + padding: '10px 14px', + border: `1.5px solid ${nameError ? '#fca5a5' : 'var(--color-border)'}`, + borderRadius: '10px', + fontSize: '14px', + color: 'var(--color-text-primary)', + background: 'white', + fontFamily: 'inherit', + boxSizing: 'border-box', + outline: 'none', + transition: 'border-color 0.15s', + }} + onFocus={e => { e.currentTarget.style.borderColor = '#10b981'; }} + onBlur={e => { e.currentTarget.style.borderColor = nameError ? '#fca5a5' : 'var(--color-border)'; }} + /> + {nameError && ( +

{nameError}

+ )} +
+ + {/* Search bar */} {/* Map */} @@ -102,19 +151,19 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn onMapClick={addPoint} /> - {/* Instructions overlay (top-left of map) */} + {/* How-to overlay */}
- How to use:
- 1. Search your village
- 2. Click corners of your field
- 3. Drop 3+ pins to form a polygon + How to use:
+ 1. Name your field above
+ 2. Search your village
+ 3. Click corners to mark boundary
@@ -124,7 +173,7 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn background: isComplete ? '#f0fdf4' : '#f9fafb', border: `1px solid ${isComplete ? '#86efac' : '#e5e7eb'}`, borderRadius: '10px', padding: '12px 16px', - transition: 'all 0.3s ease' + transition: 'all 0.3s ease', }}> {/* Pin count */}
@@ -132,27 +181,22 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn background: '#16a34a', color: 'white', borderRadius: '50%', width: '20px', height: '20px', fontSize: '11px', display: 'flex', alignItems: 'center', justifyContent: 'center', - fontWeight: 600 + fontWeight: 600, }}>{points.length} {points.length === 1 ? 'pin dropped' : 'pins dropped'}
- {/* Area display */} {isComplete && ( <> -
+
- - {area.acres} - + {area.acres} acres
- - {area.hectares} - + {area.hectares} hectares
@@ -165,7 +209,7 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn {!isComplete && points.length > 0 && ( - Drop {4 - points.length} more {4 - points.length === 1 ? 'pin' : 'pins'} to complete boundary + Drop {Math.max(0, 3 - points.length)} more {(3 - points.length) === 1 ? 'pin' : 'pins'} to complete boundary )} @@ -175,8 +219,7 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn )} - {/* Spacer */} -
+
{/* Action buttons */}
@@ -186,7 +229,7 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn style={{ padding: '6px 12px', border: '1px solid #d1d5db', borderRadius: '6px', background: 'white', - fontSize: '12px', cursor: 'pointer', color: '#374151' + fontSize: '12px', cursor: 'pointer', color: '#374151', }} > Undo last pin @@ -198,7 +241,7 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn style={{ padding: '6px 12px', border: '1px solid #fca5a5', borderRadius: '6px', background: '#fef2f2', - fontSize: '12px', cursor: 'pointer', color: '#dc2626' + fontSize: '12px', cursor: 'pointer', color: '#dc2626', }} > Reset @@ -215,14 +258,14 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn fontSize: '13px', cursor: 'pointer', color: 'white', - fontWeight: 500 + fontWeight: 500, }} > - Confirm field boundary → + Add field → )}
); -} \ No newline at end of file +} From ec84bcb584ed3bab523cccc457abeaecd13cb13f Mon Sep 17 00:00:00 2001 From: Shade555 Date: Sat, 6 Jun 2026 01:44:21 +0530 Subject: [PATCH 4/9] feat: replace single-polygon save with named multi-field management - Added Field interface to Farm type covering id, name, area, polygon coords - Replaced polygonData state with pendingField (includes fieldName) - handleSavePolygon replaced by handleAddField (calls addFarmField API) and handleDeleteField (calls deleteFarmField API) - Map tab renamed 'Field Manager' with an updated subtitle - Pending field confirm panel shows field name, area, discard/save buttons - Saved fields list renders below the mapper with coloured dot per field, name, area, and a Remove button with loading state per field - Farm cards in Farms tab now show field count when fields exist - FIELD_COLORS palette constant added for consistent colour cycling - Removed any cast in error handlers; using err instanceof Error pattern --- frontend/src/components/Dashboard.tsx | 213 +++++++++++++++++++------- 1 file changed, 158 insertions(+), 55 deletions(-) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 2bb6fb6..f562519 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -18,6 +18,16 @@ const PolygonMapper = dynamic(() => import('./map/PolygonMapper'), { loading: () =>
Loading map...
, }); +interface Field { + id: string; + name: string; + area_acres: number; + area_hectares?: number; + polygon: Array<{ lat: number; lng: number }>; + center_latitude?: number; + center_longitude?: number; +} + interface Farm { id: string; name: string; @@ -32,6 +42,7 @@ interface Farm { center_latitude?: number; center_longitude?: number; area_acres?: number; + fields?: Field[]; }; soil_type?: string; irrigation_type?: string; @@ -85,12 +96,25 @@ const FertDot: React.FC<{ color: string }> = ({ color }) => ( }} /> ); +// Palette for field colour swatches — cycles when a farm has many fields +const FIELD_COLORS = [ + '#10b981', // emerald + '#f97316', // orange + '#6366f1', // indigo + '#eab308', // yellow + '#ec4899', // pink + '#06b6d4', // cyan + '#84cc16', // lime + '#a855f7', // purple +]; + const Dashboard: React.FC = ({ activeTab, setActiveTab }) => { const router = useRouter(); const [farms, setFarms] = useState([]); const [crops, setCrops] = useState([]); const [selectedFarmId, setSelectedFarmId] = useState(''); - const [polygonData, setPolygonData] = useState<{ + const [pendingField, setPendingField] = useState<{ + fieldName: string; coordinates: LatLng[]; areaAcres: number; areaHectares: number; @@ -98,6 +122,7 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => { centerLng: number; } | null>(null); const [saving, setSaving] = useState(false); + const [deletingFieldId, setDeletingFieldId] = useState(null); const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null); const [loading, setLoading] = useState(true); @@ -143,41 +168,54 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => { }, [router.query, farms]); // Map handlers - const handleSavePolygon = async () => { - if (!polygonData || !selectedFarmId) return; + const handleAddField = async () => { + if (!pendingField || !selectedFarmId) return; setSaving(true); setSaveMessage(null); try { - await farmApi.updateFarm(selectedFarmId, { - total_area: polygonData.areaAcres, - latitude: polygonData.centerLat, - longitude: polygonData.centerLng, - location: { - ...selectedFarm?.location, - polygon: polygonData.coordinates.map(p => ({ lat: p.lat, lng: p.lng })), - center_latitude: polygonData.centerLat, - center_longitude: polygonData.centerLng, - area_acres: polygonData.areaAcres, - area_hectares: polygonData.areaHectares, - }, + await farmApi.addFarmField(selectedFarmId, { + name: pendingField.fieldName, + area_acres: pendingField.areaAcres, + area_hectares: pendingField.areaHectares, + polygon: pendingField.coordinates.map(p => ({ lat: p.lat, lng: p.lng })), + center_latitude: pendingField.centerLat, + center_longitude: pendingField.centerLng, }); - setSaveMessage({ type: 'success', text: `Boundary saved — ${polygonData.areaAcres.toFixed(1)} acres` }); + setSaveMessage({ type: 'success', text: `"${pendingField.fieldName}" saved — ${pendingField.areaAcres.toFixed(1)} acres` }); try { - const geo = await geocode(polygonData.centerLat, polygonData.centerLng); + const geo = await geocode(pendingField.centerLat, pendingField.centerLng); if (geo?.state && geo?.district) { await soilService.estimateSoilHealth(selectedFarmId, geo.state, geo.district); - setSaveMessage({ type: 'success', text: `Boundary saved & soil analysed for ${geo.district}` }); } - } catch { /* silent */ } + } catch { /* silent — soil estimation is best-effort */ } await loadData(); - setTimeout(() => { setPolygonData(null); setSaveMessage(null); }, 2500); - } catch (err: any) { - setSaveMessage({ type: 'error', text: err.message || 'Failed to save boundary' }); + setPendingField(null); + setTimeout(() => setSaveMessage(null), 3000); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to save field'; + setSaveMessage({ type: 'error', text: msg }); } finally { setSaving(false); } }; + const handleDeleteField = async (fieldId: string, fieldName: string) => { + if (!selectedFarmId) return; + setDeletingFieldId(fieldId); + setSaveMessage(null); + try { + await farmApi.deleteFarmField(selectedFarmId, fieldId); + setSaveMessage({ type: 'info', text: `"${fieldName}" removed.` }); + await loadData(); + setTimeout(() => setSaveMessage(null), 2500); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to delete field'; + setSaveMessage({ type: 'error', text: msg }); + } finally { + setDeletingFieldId(null); + } + }; + // Fertilizer calc — all values derived from farm area + selected crop const fertFarm = farms.find(f => f.id === fertFarmId) || farms[0]; const soilKey = fertFarm?.soil_type || 'default'; @@ -415,6 +453,11 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => {
{farm.total_area ? `${farm.total_area.toFixed(1)} acres` : 'Area not mapped'} — {farm.irrigation_type || 'Irrigation type not set'} + {(farm.location?.fields?.length ?? 0) > 0 && ( + + · {farm.location!.fields!.length} field{farm.location!.fields!.length !== 1 ? 's' : ''} mapped + + )}
@@ -461,16 +504,20 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => { <>
-
Boundary Mapper
+
Field Manager
- Click at least 3 corners on the map to draw your field boundary. Area is calculated automatically. + Draw and name each field boundary. You can add multiple fields per farm.
{hasFarms && ( @@ -486,13 +533,13 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => { {!hasFarms ? (
No farm to map
-
Create a farm first, then draw its boundary here.
+
Create a farm first, then draw its field boundaries here.
) : ( <> - {/* PolygonMapper: do NOT constrain height here — let it render fully */} -
+ {/* ── Polygon drawing tool ── */} +
= ({ activeTab, setActiveTab }) => { ? { lat: selectedFarm.location.latitude, lng: selectedFarm.location.longitude } : { lat: 22.5726, lng: 88.3639 } } - onPolygonComplete={data => setPolygonData(data)} + onPolygonComplete={data => setPendingField(data)} />
- {/* Confirm area panel — shown when polygon data available */} - {polygonData && ( + {/* ── Pending field confirm panel ── */} + {pendingField && (
= ({ activeTab, setActiveTab }) => { boxShadow: '0 2px 12px rgba(16,185,129,0.1)', }}>
-
Field Area
-
- {polygonData.areaAcres.toFixed(2)} - Acres +
+ Ready to save
-
- {polygonData.areaHectares.toFixed(2)} Hectares +
+ {pendingField.fieldName} +
+
+ {pendingField.areaAcres.toFixed(2)} acres  ·  {pendingField.areaHectares.toFixed(2)} ha
)} + + {/* ── Saved fields list ── */} + {(() => { + const fields = selectedFarm?.location?.fields ?? []; + return fields.length > 0 ? ( +
+
+
+ Mapped Fields +
+
+ {fields.length} field{fields.length !== 1 ? 's' : ''}  ·  {selectedFarm?.total_area?.toFixed(1) ?? '0'} acres total +
+
+ {fields.map((field, idx) => ( +
+ {/* Colour swatch — cycles through a palette */} +
+
+
+ {field.name} +
+
+ {field.area_acres.toFixed(2)} acres + {field.area_hectares ? ` · ${field.area_hectares.toFixed(2)} ha` : ''} +
+
+ +
+ ))} +
+ ) : ( +
+ No fields mapped yet. Draw a boundary above and give it a name to add your first field. +
+ ); + })()} )} From d392ab4ef79260e0a81862e935c59bc591f4a63d Mon Sep 17 00:00:00 2001 From: Shade555 Date: Sat, 6 Jun 2026 01:44:48 +0530 Subject: [PATCH 5/9] feat: render all named field polygons with labels in FarmMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Each named field gets a distinct colour from the FIELD_COLORS palette - Field popups show field name, area in acres/ha, and parent farm name - Legacy farm.location.polygon still renders for backwards compatibility (only shown when a farm has no named fields yet) - Farm marker position derived from first field centre, then legacy polygon centroid, then explicit lat/lng — graceful fallback chain - Removed unused showAllFields prop; all fields always shown when present - Error handling uses err instanceof Error instead of any cast --- frontend/src/components/FarmMap.tsx | 257 ++++++++++++++++------------ 1 file changed, 149 insertions(+), 108 deletions(-) diff --git a/frontend/src/components/FarmMap.tsx b/frontend/src/components/FarmMap.tsx index 1ccfe45..cdbe9e2 100644 --- a/frontend/src/components/FarmMap.tsx +++ b/frontend/src/components/FarmMap.tsx @@ -11,11 +11,32 @@ const DefaultIcon = L.icon({ iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], - shadowSize: [41, 41] + shadowSize: [41, 41], }); - L.Marker.prototype.options.icon = DefaultIcon; +// Palette that matches FIELD_COLORS in Dashboard — cycles for many fields +const FIELD_COLORS = [ + '#10b981', + '#f97316', + '#6366f1', + '#eab308', + '#ec4899', + '#06b6d4', + '#84cc16', + '#a855f7', +]; + +interface Field { + id: string; + name: string; + area_acres: number; + area_hectares?: number; + polygon: Array<{ lat: number; lng: number }>; + center_latitude?: number; + center_longitude?: number; +} + interface Farm { id: string; name: string; @@ -25,17 +46,9 @@ interface Farm { state?: string; district?: string; village?: string; - polygon?: Array<{lat: number, lng: number}>; - fields?: Array<{ - id: string; - name: string; - area_acres: number; - area_hectares?: number; - polygon: Array<{lat: number, lng: number}>; - center_latitude?: number; - center_longitude?: number; - created_at?: string; - }>; + /** Legacy single-polygon stored directly on the farm */ + polygon?: Array<{ lat: number; lng: number }>; + fields?: Field[]; }; total_area: number; soil_type?: string; @@ -48,18 +61,16 @@ interface FarmMapProps { centerLng?: number; zoom?: number; showAllFarms?: boolean; - showAllFields?: boolean; height?: string; } -const FarmMap: React.FC = ({ - farmId, - centerLat = 20.5937, +const FarmMap: React.FC = ({ + farmId, + centerLat = 20.5937, centerLng = 78.9629, zoom = 5, showAllFarms = false, - showAllFields = false, - height = '400px' + height = '400px', }) => { const [farms, setFarms] = useState([]); const [loading, setLoading] = useState(true); @@ -71,50 +82,55 @@ const FarmMap: React.FC = ({ const loadFarmData = async () => { try { setLoading(true); - + if (farmId) { const farm = await farmApi.getFarm(farmId); if (farm) { setFarms([farm]); - if (farm.location?.latitude && farm.location?.longitude) { - setMapCenter([farm.location.latitude, farm.location.longitude]); + // Prefer centre from named fields, then legacy polygon centroid, then explicit lat/lng + const firstField = farm.location?.fields?.[0]; + if (firstField?.center_latitude && firstField?.center_longitude) { + setMapCenter([firstField.center_latitude, firstField.center_longitude]); setMapZoom(16); } else if (farm.location?.polygon && farm.location.polygon.length >= 3) { - const polygon = farm.location.polygon; - const centerLat = polygon.reduce((sum: number, p: {lat: number}) => sum + p.lat, 0) / polygon.length; - const centerLng = polygon.reduce((sum: number, p: {lng: number}) => sum + p.lng, 0) / polygon.length; - setMapCenter([centerLat, centerLng]); + const poly = farm.location.polygon; + setMapCenter([ + poly.reduce((s: number, p: { lat: number }) => s + p.lat, 0) / poly.length, + poly.reduce((s: number, p: { lng: number }) => s + p.lng, 0) / poly.length, + ]); + setMapZoom(16); + } else if (farm.location?.latitude && farm.location?.longitude) { + setMapCenter([farm.location.latitude, farm.location.longitude]); setMapZoom(16); } } else { - setFarms([]); setError('Farm not found'); } } else if (showAllFarms) { - const allFarms = await farmApi.getFarms(); - const farmsWithCoords = allFarms.filter( - (farm: Farm) => (farm.location?.latitude && farm.location?.longitude) || - (farm.location?.polygon && farm.location.polygon.length >= 3) + const allFarms: Farm[] = await farmApi.getFarms(); + const farmsWithData = allFarms.filter( + f => + (f.location?.fields?.length ?? 0) > 0 || + (f.location?.polygon?.length ?? 0) >= 3 || + (f.location?.latitude && f.location?.longitude) ); - - setFarms(farmsWithCoords); - - if (farmsWithCoords.length > 0) { - const firstFarm = farmsWithCoords[0]; - if (firstFarm.location?.latitude && firstFarm.location?.longitude) { - setMapCenter([firstFarm.location.latitude, firstFarm.location.longitude]); - } else if (firstFarm.location?.polygon && firstFarm.location.polygon.length >= 3) { - const polygon = firstFarm.location.polygon; - const centerLat = polygon.reduce((sum: number, p: {lat: number}) => sum + p.lat, 0) / polygon.length; - const centerLng = polygon.reduce((sum: number, p: {lng: number}) => sum + p.lng, 0) / polygon.length; - setMapCenter([centerLat, centerLng]); + setFarms(farmsWithData); + + if (farmsWithData.length > 0) { + const first = farmsWithData[0]; + const firstField = first.location?.fields?.[0]; + if (firstField?.center_latitude && firstField?.center_longitude) { + setMapCenter([firstField.center_latitude, firstField.center_longitude]); + } else if (first.location?.latitude && first.location?.longitude) { + setMapCenter([first.location.latitude, first.location.longitude]); } setMapZoom(12); } } - } catch (err: any) { - console.error('Error loading farm data:', err); - setError(err.message || 'Failed to load farm data'); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to load farm data'; + console.error('FarmMap load error:', err); + setError(msg); } finally { setLoading(false); } @@ -134,17 +150,17 @@ const FarmMap: React.FC = ({ if (farms.length === 0) { return (
-

- No farms with location data available.
- Use "Draw Field" to add field boundaries. +

+ No farms with location data available.
+ Use "Field Manager" to add field boundaries.

); } - const heightClass = - height === '100%' ? styles.mapHeightFull : - height === '250px' ? styles.mapHeightSmall : + const heightClass = + height === '100%' ? styles.mapHeightFull : + height === '250px' ? styles.mapHeightSmall : styles.mapHeight; return ( @@ -158,70 +174,95 @@ const FarmMap: React.FC = ({ attribution='© OpenStreetMap' url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> + {farms.map(farm => { - const hasPolygon = farm.location?.polygon && farm.location.polygon.length >= 3; - const hasFields = farm.location?.fields && farm.location.fields.length > 0; - const lat = farm.location?.latitude; - const lng = farm.location?.longitude; - - // Derive center from polygon centroid if GPS lat/lng not set - const polyCenter = hasPolygon ? { - lat: farm.location!.polygon!.reduce((s: number, p: {lat: number}) => s + p.lat, 0) / farm.location!.polygon!.length, - lng: farm.location!.polygon!.reduce((s: number, p: {lng: number}) => s + p.lng, 0) / farm.location!.polygon!.length, - } : null; - - const centerLat = lat ?? polyCenter?.lat ?? farm.location?.fields?.[0]?.center_latitude; - const centerLng = lng ?? polyCenter?.lng ?? farm.location?.fields?.[0]?.center_longitude; - - - if (!centerLat || !centerLng) return null; - + const fields: Field[] = farm.location?.fields ?? []; + const hasFields = fields.length > 0; + const hasLegacyPolygon = + !hasFields && + (farm.location?.polygon?.length ?? 0) >= 3; + + // Marker position: first field centre → legacy polygon centroid → explicit lat/lng + const markerLat = + fields[0]?.center_latitude ?? + farm.location?.latitude ?? + (hasLegacyPolygon + ? farm.location!.polygon!.reduce((s, p) => s + p.lat, 0) / farm.location!.polygon!.length + : undefined); + const markerLng = + fields[0]?.center_longitude ?? + farm.location?.longitude ?? + (hasLegacyPolygon + ? farm.location!.polygon!.reduce((s, p) => s + p.lng, 0) / farm.location!.polygon!.length + : undefined); + return ( - {showAllFields && hasFields ? ( - farm.location.fields!.map((field, idx) => ( - field.polygon && field.polygon.length >= 3 && ( - [p.lat, p.lng])} - pathOptions={{ color: '#8b5cf6', fillColor: '#8b5cf6', fillOpacity: 0.3, weight: 3 }} + {/* Named fields — each gets its own colour */} + {hasFields && + fields.map((field, idx) => { + if (!field.polygon || field.polygon.length < 3) return null; + const color = FIELD_COLORS[idx % FIELD_COLORS.length]; + return ( + [p.lat, p.lng] as [number, number])} + pathOptions={{ color, fillColor: color, fillOpacity: 0.25, weight: 2.5 }} > -
-

{field.name}

-

Area: {field.area_acres} acres

- {field.area_hectares &&

Area: {field.area_hectares} ha

} +
+
{field.name}
+
+ {field.area_acres.toFixed(2)} acres + {field.area_hectares ? ` · ${field.area_hectares.toFixed(2)} ha` : ''} +
+
{farm.name}
- ) - )) - ) : hasPolygon && ( - [p.lat, p.lng])} - pathOptions={{ color: '#16a34a', fillColor: '#16a34a', fillOpacity: 0.3, weight: 3 }} - /> + ); + })} + + {/* Legacy single polygon — shown only if no named fields exist */} + {hasLegacyPolygon && ( + [p.lat, p.lng] as [number, number])} + pathOptions={{ color: '#16a34a', fillColor: '#16a34a', fillOpacity: 0.25, weight: 2.5 }} + > + +
{farm.name}
+
+
+ )} + + {/* Farm marker */} + {markerLat !== undefined && markerLng !== undefined && ( + + +
+
{farm.name}
+
+ {farm.total_area.toFixed(1)} acres total +
+ {hasFields && ( +
+ {fields.length} field{fields.length !== 1 ? 's' : ''} mapped +
+ )} + {farm.soil_type && ( +
Soil: {farm.soil_type}
+ )} + {(farm.location?.village || farm.location?.district || farm.location?.state) && ( +
+ {[farm.location?.village, farm.location?.district, farm.location?.state] + .filter(Boolean) + .join(', ')} +
+ )} +
+
+
)} - - -
-

{farm.name}

-

Area: {farm.total_area} acres

- {farm.soil_type &&

Soil: {farm.soil_type}

} - {farm.irrigation_type &&

Irri: {farm.irrigation_type}

} - {hasPolygon && ( -

- ✓ Field boundary mapped ({farm.location.polygon!.length} points) -

- )} - {(farm.location?.village || farm.location?.district || farm.location?.state) && ( -

- {[farm.location?.village, farm.location?.district, farm.location?.state].filter(Boolean).join(', ')} -

- )} -
-
-
); })} @@ -230,4 +271,4 @@ const FarmMap: React.FC = ({ ); }; -export default FarmMap; \ No newline at end of file +export default FarmMap; From da65ec7c930d873567ef083880ae8eae7652f60d Mon Sep 17 00:00:00 2001 From: Shade555 Date: Sat, 6 Jun 2026 01:52:39 +0530 Subject: [PATCH 6/9] chore: align code with CONTRIBUTING.md standards TypeScript: - Extract FIELD_COLORS into frontend/src/utils/mapUtils.ts so Dashboard and FarmMap share a single source of truth (no duplication) - Remove unused showInstructions prop from PolygonMapper - Remove unused locationLoading state from PolygonMapper - Import FIELD_COLORS from mapUtils in Dashboard and FarmMap Python: - Add explicit return type annotation (-> dict) to get_fields, add_field, and delete_field endpoints - Add user: dict type annotation on the Depends(verify_token) parameter in all three field endpoints - Reformat long chained Supabase calls onto multiple lines (PEP 8) - Add current_loc: dict and current_fields: list local type annotations --- backend/main.py | 59 +++++++++++++++---- frontend/src/components/Dashboard.tsx | 14 +---- frontend/src/components/FarmMap.tsx | 13 +--- frontend/src/components/map/PolygonMapper.tsx | 7 +-- frontend/src/utils/mapUtils.ts | 21 +++++++ 5 files changed, 74 insertions(+), 40 deletions(-) create mode 100644 frontend/src/utils/mapUtils.ts diff --git a/backend/main.py b/backend/main.py index 1822a7e..9807e86 100644 --- a/backend/main.py +++ b/backend/main.py @@ -627,21 +627,43 @@ async def delete_farm(farm_id: str, user=Depends(verify_token)): # ── Farm Fields (Polygon) ───────────────────────────────────────────────────── @app.get("/api/farms/{farm_id}/fields") -async def get_fields(farm_id: str, user=Depends(verify_token)): - res = supabase.table("farms").select("location").eq("id", farm_id).eq("farmer_id", user.id).limit(1).execute() +async def get_fields( + farm_id: str, + user: dict = Depends(verify_token), +) -> dict: + res = ( + supabase.table("farms") + .select("location") + .eq("id", farm_id) + .eq("farmer_id", user.id) + .limit(1) + .execute() + ) if not res.data: raise HTTPException(status_code=404, detail="Farm not found") fields = (res.data[0].get("location") or {}).get("fields", []) return {"success": True, "data": fields} + @app.post("/api/farms/{farm_id}/fields", status_code=201) -async def add_field(farm_id: str, body: FieldCreate, user=Depends(verify_token)): - owned = supabase.table("farms").select("location, total_area").eq("id", farm_id).eq("farmer_id", user.id).limit(1).execute() +async def add_field( + farm_id: str, + body: FieldCreate, + user: dict = Depends(verify_token), +) -> dict: + owned = ( + supabase.table("farms") + .select("location, total_area") + .eq("id", farm_id) + .eq("farmer_id", user.id) + .limit(1) + .execute() + ) if not owned.data: raise HTTPException(status_code=403, detail="Access denied") - current_loc = (owned.data[0].get("location") or {}) - current_fields = current_loc.get("fields", []) + current_loc: dict = owned.data[0].get("location") or {} + current_fields: list = current_loc.get("fields", []) new_field = { "id": str(uuid.uuid4()), @@ -657,16 +679,31 @@ async def add_field(farm_id: str, body: FieldCreate, user=Depends(verify_token)) return {"success": True, "data": new_field} + @app.delete("/api/farms/{farm_id}/fields/{field_id}") -async def delete_field(farm_id: str, field_id: str, user=Depends(verify_token)): - owned = supabase.table("farms").select("location, total_area").eq("id", farm_id).eq("farmer_id", user.id).limit(1).execute() +async def delete_field( + farm_id: str, + field_id: str, + user: dict = Depends(verify_token), +) -> dict: + owned = ( + supabase.table("farms") + .select("location, total_area") + .eq("id", farm_id) + .eq("farmer_id", user.id) + .limit(1) + .execute() + ) if not owned.data: raise HTTPException(status_code=403, detail="Access denied") - current_loc = (owned.data[0].get("location") or {}) - current_fields = current_loc.get("fields", []) + current_loc: dict = owned.data[0].get("location") or {} + current_fields: list = current_loc.get("fields", []) updated_fields = [f for f in current_fields if f.get("id") != field_id] - total_area = sum(f.get("area_acres", 0) for f in updated_fields) or owned.data[0].get("total_area", 0) + total_area = ( + sum(f.get("area_acres", 0) for f in updated_fields) + or owned.data[0].get("total_area", 0) + ) supabase.table("farms").update({ "location": {**current_loc, "fields": updated_fields}, diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index f562519..d8484b7 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -5,6 +5,7 @@ import s from '../styles/Dashboard.module.css'; import { farmApi, cropApi } from '../utils/farmApi'; import { soilService } from '../utils/soilService'; import { useReverseGeocode } from '../hooks/useReverseGeocode'; +import { FIELD_COLORS } from '../utils/mapUtils'; import type { LatLng } from '../utils/geoUtils'; import ProfileTab from './ProfileTab'; import AnalyticsDashboard from './AnalyticsDashboard'; @@ -96,17 +97,8 @@ const FertDot: React.FC<{ color: string }> = ({ color }) => ( }} /> ); -// Palette for field colour swatches — cycles when a farm has many fields -const FIELD_COLORS = [ - '#10b981', // emerald - '#f97316', // orange - '#6366f1', // indigo - '#eab308', // yellow - '#ec4899', // pink - '#06b6d4', // cyan - '#84cc16', // lime - '#a855f7', // purple -]; +// Palette for field colour swatches — imported from shared mapUtils +// (FIELD_COLORS is used by both Dashboard and FarmMap to keep colours in sync) const Dashboard: React.FC = ({ activeTab, setActiveTab }) => { const router = useRouter(); diff --git a/frontend/src/components/FarmMap.tsx b/frontend/src/components/FarmMap.tsx index cdbe9e2..538e4aa 100644 --- a/frontend/src/components/FarmMap.tsx +++ b/frontend/src/components/FarmMap.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { MapContainer, TileLayer, Marker, Popup, Polygon } from 'react-leaflet'; import L from 'leaflet'; import { farmApi } from '../utils/farmApi'; +import { FIELD_COLORS } from '../utils/mapUtils'; import styles from '../styles/Map.module.css'; // Fix for Leaflet marker icons in Next.js @@ -15,18 +16,6 @@ const DefaultIcon = L.icon({ }); L.Marker.prototype.options.icon = DefaultIcon; -// Palette that matches FIELD_COLORS in Dashboard — cycles for many fields -const FIELD_COLORS = [ - '#10b981', - '#f97316', - '#6366f1', - '#eab308', - '#ec4899', - '#06b6d4', - '#84cc16', - '#a855f7', -]; - interface Field { id: string; name: string; diff --git a/frontend/src/components/map/PolygonMapper.tsx b/frontend/src/components/map/PolygonMapper.tsx index 24cfaae..ab4eeb6 100644 --- a/frontend/src/components/map/PolygonMapper.tsx +++ b/frontend/src/components/map/PolygonMapper.tsx @@ -32,31 +32,26 @@ interface PolygonCompleteData { interface PolygonMapperProps { onPolygonComplete: (data: PolygonCompleteData) => void; initialCenter?: LatLng; - showInstructions?: boolean; } export default function PolygonMapper({ onPolygonComplete, initialCenter, - showInstructions = true, }: PolygonMapperProps) { const { points, area, center, isComplete, addPoint, removeLastPoint, resetPolygon } = usePolygonArea(); const [mapCenter, setMapCenter] = useState(initialCenter || { lat: 22.5726, lng: 88.3639 }); const [mapZoom, setMapZoom] = useState(13); - const [locationLoading, setLocationLoading] = useState(false); const [fieldName, setFieldName] = useState(''); const [nameError, setNameError] = useState(''); useEffect(() => { if (!initialCenter && navigator.geolocation) { - setLocationLoading(true); navigator.geolocation.getCurrentPosition( (position) => { setMapCenter({ lat: position.coords.latitude, lng: position.coords.longitude }); setMapZoom(15); - setLocationLoading(false); }, - () => { setLocationLoading(false); } + () => { /* geolocation denied — keep default centre */ } ); } else if (initialCenter) { setMapCenter(initialCenter); diff --git a/frontend/src/utils/mapUtils.ts b/frontend/src/utils/mapUtils.ts new file mode 100644 index 0000000..a46fa09 --- /dev/null +++ b/frontend/src/utils/mapUtils.ts @@ -0,0 +1,21 @@ +/** + * Shared map utilities + * + * Constants and helpers used by both the Dashboard (field list swatches) + * and FarmMap (polygon rendering) so the colours stay in sync. + */ + +/** + * Colour palette for named field polygons and swatches. + * Index cycles when a farm has more fields than palette entries. + */ +export const FIELD_COLORS: readonly string[] = [ + '#10b981', // emerald + '#f97316', // orange + '#6366f1', // indigo + '#eab308', // yellow + '#ec4899', // pink + '#06b6d4', // cyan + '#84cc16', // lime + '#a855f7', // purple +] as const; From 9169660199ebf61414799b5e4f2c3fcc2fdd1df1 Mon Sep 17 00:00:00 2001 From: Shade555 Date: Sat, 6 Jun 2026 02:02:39 +0530 Subject: [PATCH 7/9] fix: correct pin count text to match usePolygonArea threshold of 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook uses isComplete: points.length >= 4 (by design — a rectangle needs 4 corners). Updated all UI copy that said '3+' to say '4', and fixed the 'Drop X more pins' counter which was subtracting from 3 instead of 4. --- frontend/src/components/map/PolygonMapper.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/map/PolygonMapper.tsx b/frontend/src/components/map/PolygonMapper.tsx index ab4eeb6..8fb5722 100644 --- a/frontend/src/components/map/PolygonMapper.tsx +++ b/frontend/src/components/map/PolygonMapper.tsx @@ -94,7 +94,7 @@ export default function PolygonMapper({ Draw a field boundary

- Name the field, then click 3+ corners on the map to draw its boundary. + Name the field, then click 4 corners on the map to draw its boundary.

@@ -158,7 +158,7 @@ export default function PolygonMapper({ How to use:
1. Name your field above
2. Search your village
- 3. Click corners to mark boundary + 3. Click 4 corners to mark boundary
@@ -204,7 +204,7 @@ export default function PolygonMapper({ {!isComplete && points.length > 0 && ( - Drop {Math.max(0, 3 - points.length)} more {(3 - points.length) === 1 ? 'pin' : 'pins'} to complete boundary + Drop {Math.max(0, 4 - points.length)} more {(4 - points.length) === 1 ? 'pin' : 'pins'} to complete boundary )} From 246708b4b7e66ab422ece8dd7579ce379e0c07c7 Mon Sep 17 00:00:00 2001 From: Shade555 Date: Sat, 6 Jun 2026 19:14:39 +0530 Subject: [PATCH 8/9] feat: add farm_fields table with RLS, indexes, and area trigger --- backend/main.py | 74 ++++--- .../20260607000000_farm_fields_table.sql | 198 ++++++++++++++++++ frontend/src/components/Dashboard.tsx | 30 ++- frontend/src/components/FarmMap.tsx | 85 ++++---- 4 files changed, 310 insertions(+), 77 deletions(-) create mode 100644 backend/supabase/migrations/20260607000000_farm_fields_table.sql diff --git a/backend/main.py b/backend/main.py index 9807e86..d777397 100644 --- a/backend/main.py +++ b/backend/main.py @@ -223,12 +223,23 @@ class YieldCreate(BaseModel): quality_notes: Optional[str] = None class FieldCreate(BaseModel): + name: str + area_acres: float + polygon: List[Dict[str, float]] + center_latitude: Optional[float] = None + center_longitude: Optional[float] = None + + +class FieldResponse(BaseModel): + id: str + farm_id: str name: str area_acres: float area_hectares: Optional[float] = None polygon: List[Dict[str, float]] center_latitude: Optional[float] = None center_longitude: Optional[float] = None + created_at: Optional[str] = None class SoilEstimationRequest(BaseModel): farm_id: str @@ -631,18 +642,26 @@ async def get_fields( farm_id: str, user: dict = Depends(verify_token), ) -> dict: - res = ( + # Verify farm ownership before returning fields + owned = ( supabase.table("farms") - .select("location") + .select("id") .eq("id", farm_id) .eq("farmer_id", user.id) .limit(1) .execute() ) - if not res.data: + if not owned.data: raise HTTPException(status_code=404, detail="Farm not found") - fields = (res.data[0].get("location") or {}).get("fields", []) - return {"success": True, "data": fields} + + res = ( + supabase.table("farm_fields") + .select("*") + .eq("farm_id", farm_id) + .order("created_at") + .execute() + ) + return {"success": True, "data": res.data or []} @app.post("/api/farms/{farm_id}/fields", status_code=201) @@ -651,9 +670,10 @@ async def add_field( body: FieldCreate, user: dict = Depends(verify_token), ) -> dict: + # Verify farm ownership owned = ( supabase.table("farms") - .select("location, total_area") + .select("id") .eq("id", farm_id) .eq("farmer_id", user.id) .limit(1) @@ -662,22 +682,22 @@ async def add_field( if not owned.data: raise HTTPException(status_code=403, detail="Access denied") - current_loc: dict = owned.data[0].get("location") or {} - current_fields: list = current_loc.get("fields", []) - - new_field = { - "id": str(uuid.uuid4()), - **body.model_dump(), + payload: dict = { + "farm_id": farm_id, + "name": body.name, + "area_acres": body.area_acres, + "polygon": body.polygon, + "center_latitude": body.center_latitude, + "center_longitude": body.center_longitude, } - updated_fields = current_fields + [new_field] - total_area = sum(f.get("area_acres", 0) for f in updated_fields) + # Strip None values so DB defaults apply + payload = {k: v for k, v in payload.items() if v is not None} - supabase.table("farms").update({ - "location": {**current_loc, "fields": updated_fields}, - "total_area": total_area, - }).eq("id", farm_id).execute() + res = supabase.table("farm_fields").insert(payload).execute() + if not res.data: + raise HTTPException(status_code=500, detail="Failed to save field") - return {"success": True, "data": new_field} + return {"success": True, "data": res.data[0]} @app.delete("/api/farms/{farm_id}/fields/{field_id}") @@ -686,9 +706,10 @@ async def delete_field( field_id: str, user: dict = Depends(verify_token), ) -> dict: + # Verify farm ownership owned = ( supabase.table("farms") - .select("location, total_area") + .select("id") .eq("id", farm_id) .eq("farmer_id", user.id) .limit(1) @@ -697,18 +718,7 @@ async def delete_field( if not owned.data: raise HTTPException(status_code=403, detail="Access denied") - current_loc: dict = owned.data[0].get("location") or {} - current_fields: list = current_loc.get("fields", []) - updated_fields = [f for f in current_fields if f.get("id") != field_id] - total_area = ( - sum(f.get("area_acres", 0) for f in updated_fields) - or owned.data[0].get("total_area", 0) - ) - - supabase.table("farms").update({ - "location": {**current_loc, "fields": updated_fields}, - "total_area": total_area, - }).eq("id", farm_id).execute() + supabase.table("farm_fields").delete().eq("id", field_id).eq("farm_id", farm_id).execute() return {"success": True, "message": "Field deleted"} diff --git a/backend/supabase/migrations/20260607000000_farm_fields_table.sql b/backend/supabase/migrations/20260607000000_farm_fields_table.sql new file mode 100644 index 0000000..3a450bf --- /dev/null +++ b/backend/supabase/migrations/20260607000000_farm_fields_table.sql @@ -0,0 +1,198 @@ +-- ============================================================================= +-- AgroNavis — Migration 6: Dedicated farm_fields table +-- Applied: 2026-06-07 +-- +-- Replaces the JSONB fields array embedded in farms.location with a proper +-- relational table. This enables: +-- • RLS policies scoped to individual fields +-- • Indexed spatial lookups by farm +-- • Clean foreign-key cascade deletes +-- • Accurate total_area derived via DB aggregation +-- +-- Backwards compatibility: farms.location.polygon (legacy single-polygon) +-- is left untouched so existing data continues to render in FarmMap. +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 1. Create farm_fields table +-- ----------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS public.farm_fields ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + farm_id uuid NOT NULL REFERENCES public.farms(id) ON DELETE CASCADE, + + name text NOT NULL, + area_acres numeric NOT NULL CHECK (area_acres > 0), + area_hectares numeric GENERATED ALWAYS AS (area_acres * 0.404686) STORED, + + -- Polygon stored as JSONB array of {lat, lng} points + -- e.g. [{"lat": 22.57, "lng": 88.36}, ...] + polygon jsonb NOT NULL, + + center_latitude numeric, + center_longitude numeric, + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT farm_fields_pkey PRIMARY KEY (id) +); + +-- Index: fast lookup of all fields for a given farm +CREATE INDEX IF NOT EXISTS idx_farm_fields_farm_id + ON public.farm_fields (farm_id); + +-- Index: spatial-ish lookup by centre coordinates +CREATE INDEX IF NOT EXISTS idx_farm_fields_center + ON public.farm_fields (center_latitude, center_longitude) + WHERE center_latitude IS NOT NULL AND center_longitude IS NOT NULL; + + +-- ----------------------------------------------------------------------------- +-- 2. updated_at trigger +-- ----------------------------------------------------------------------------- + +CREATE TRIGGER set_farm_fields_updated_at + BEFORE UPDATE ON public.farm_fields + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + + +-- ----------------------------------------------------------------------------- +-- 3. Row Level Security +-- Farmers can only see and modify fields that belong to their own farms. +-- ----------------------------------------------------------------------------- + +ALTER TABLE public.farm_fields ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Farmers can view fields on own farms" + ON public.farm_fields FOR SELECT + USING ( + auth.uid() IN ( + SELECT farmer_id FROM public.farms WHERE id = farm_id + ) + ); + +CREATE POLICY "Farmers can insert fields on own farms" + ON public.farm_fields FOR INSERT + WITH CHECK ( + auth.uid() IN ( + SELECT farmer_id FROM public.farms WHERE id = farm_id + ) + ); + +CREATE POLICY "Farmers can update fields on own farms" + ON public.farm_fields FOR UPDATE + USING ( + auth.uid() IN ( + SELECT farmer_id FROM public.farms WHERE id = farm_id + ) + ); + +CREATE POLICY "Farmers can delete fields on own farms" + ON public.farm_fields FOR DELETE + USING ( + auth.uid() IN ( + SELECT farmer_id FROM public.farms WHERE id = farm_id + ) + ); + + +-- ----------------------------------------------------------------------------- +-- 4. Auto-update farms.total_area when fields change +-- Keeps the farms row in sync without needing application-level bookkeeping. +-- ----------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.sync_farm_total_area() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + v_farm_id uuid; + v_total numeric; +BEGIN + -- Determine the affected farm_id from either NEW or OLD row + v_farm_id := COALESCE(NEW.farm_id, OLD.farm_id); + + SELECT COALESCE(SUM(area_acres), 0) + INTO v_total + FROM public.farm_fields + WHERE farm_id = v_farm_id; + + UPDATE public.farms + SET total_area = v_total + WHERE id = v_farm_id; + + RETURN COALESCE(NEW, OLD); +END; +$$; + +CREATE TRIGGER trg_sync_farm_total_area + AFTER INSERT OR UPDATE OR DELETE ON public.farm_fields + FOR EACH ROW + EXECUTE FUNCTION public.sync_farm_total_area(); + + +-- ----------------------------------------------------------------------------- +-- 5. Migrate existing JSONB fields into the new table +-- Reads farms.location->>'fields' and inserts each entry as a proper row. +-- Safe to run even if the JSONB array is empty or missing. +-- ----------------------------------------------------------------------------- + +DO $$ +DECLARE + r RECORD; + field_row jsonb; +BEGIN + FOR r IN + SELECT id, location + FROM public.farms + WHERE location IS NOT NULL + AND location->'fields' IS NOT NULL + AND jsonb_array_length(location->'fields') > 0 + LOOP + FOR field_row IN + SELECT jsonb_array_elements(r.location->'fields') + LOOP + INSERT INTO public.farm_fields ( + id, + farm_id, + name, + area_acres, + polygon, + center_latitude, + center_longitude + ) VALUES ( + COALESCE((field_row->>'id')::uuid, gen_random_uuid()), + r.id, + COALESCE(field_row->>'name', 'Unnamed Field'), + COALESCE((field_row->>'area_acres')::numeric, 0), + COALESCE(field_row->'polygon', '[]'::jsonb), + (field_row->>'center_latitude')::numeric, + (field_row->>'center_longitude')::numeric + ) + ON CONFLICT (id) DO NOTHING; + END LOOP; + END LOOP; +END; +$$; + + +-- ----------------------------------------------------------------------------- +-- 6. Grant service_role access (used by FastAPI backend) +-- ----------------------------------------------------------------------------- + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.farm_fields TO service_role; + + +-- ============================================================================= +-- DONE +-- Table: farm_fields (id, farm_id, name, area_acres, area_hectares, +-- polygon, center_latitude, center_longitude, +-- created_at, updated_at) +-- Trigger: trg_sync_farm_total_area — keeps farms.total_area accurate +-- Trigger: set_farm_fields_updated_at — auto-updates updated_at +-- RLS: SELECT / INSERT / UPDATE / DELETE scoped to farm owner +-- Index: idx_farm_fields_farm_id, idx_farm_fields_center +-- Migration: existing JSONB fields copied into new table +-- ============================================================================= diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index d8484b7..0dccbba 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -43,7 +43,6 @@ interface Farm { center_latitude?: number; center_longitude?: number; area_acres?: number; - fields?: Field[]; }; soil_type?: string; irrigation_type?: string; @@ -105,6 +104,7 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => { const [farms, setFarms] = useState([]); const [crops, setCrops] = useState([]); const [selectedFarmId, setSelectedFarmId] = useState(''); + const [fields, setFields] = useState([]); const [pendingField, setPendingField] = useState<{ fieldName: string; coordinates: LatLng[]; @@ -148,6 +148,14 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => { useEffect(() => { loadData(); }, [loadData]); + // Reload fields from the dedicated table whenever the selected farm changes + useEffect(() => { + if (!selectedFarmId) return; + farmApi.getFarmFields(selectedFarmId) + .then((data: Field[]) => setFields(data || [])) + .catch(() => setFields([])); + }, [selectedFarmId]); + // Handle ?mode=draw from onboarding redirect useEffect(() => { const mode = router.query.mode as string; @@ -180,7 +188,10 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => { await soilService.estimateSoilHealth(selectedFarmId, geo.state, geo.district); } } catch { /* silent — soil estimation is best-effort */ } + // Refresh both farms (for total_area) and the fields list await loadData(); + const refreshed = await farmApi.getFarmFields(selectedFarmId); + setFields(refreshed || []); setPendingField(null); setTimeout(() => setSaveMessage(null), 3000); } catch (err: unknown) { @@ -198,7 +209,10 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => { try { await farmApi.deleteFarmField(selectedFarmId, fieldId); setSaveMessage({ type: 'info', text: `"${fieldName}" removed.` }); + // Refresh both farms (total_area updated by DB trigger) and fields list await loadData(); + const refreshed = await farmApi.getFarmFields(selectedFarmId); + setFields(refreshed || []); setTimeout(() => setSaveMessage(null), 2500); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Failed to delete field'; @@ -445,9 +459,9 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => {
{farm.total_area ? `${farm.total_area.toFixed(1)} acres` : 'Area not mapped'} — {farm.irrigation_type || 'Irrigation type not set'} - {(farm.location?.fields?.length ?? 0) > 0 && ( + {farm.id === selectedFarmId && fields.length > 0 && ( - · {farm.location!.fields!.length} field{farm.location!.fields!.length !== 1 ? 's' : ''} mapped + · {fields.length} field{fields.length !== 1 ? 's' : ''} mapped )}
@@ -531,7 +545,7 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => { ) : ( <> {/* ── Polygon drawing tool ── */} -
+
= ({ activeTab, setActiveTab }) => { )} {/* ── Saved fields list ── */} - {(() => { - const fields = selectedFarm?.location?.fields ?? []; - return fields.length > 0 ? ( + {fields.length > 0 ? (
@@ -623,7 +635,6 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => { borderBottom: idx < fields.length - 1 ? '1px solid var(--color-border)' : 'none', }} > - {/* Colour swatch — cycles through a palette */}
= ({ activeTab, setActiveTab }) => {
No fields mapped yet. Draw a boundary above and give it a name to add your first field.
- ); - })()} + )} )} diff --git a/frontend/src/components/FarmMap.tsx b/frontend/src/components/FarmMap.tsx index 538e4aa..333dd77 100644 --- a/frontend/src/components/FarmMap.tsx +++ b/frontend/src/components/FarmMap.tsx @@ -37,7 +37,6 @@ interface Farm { village?: string; /** Legacy single-polygon stored directly on the farm */ polygon?: Array<{ lat: number; lng: number }>; - fields?: Field[]; }; total_area: number; soil_type?: string; @@ -62,6 +61,8 @@ const FarmMap: React.FC = ({ height = '400px', }) => { const [farms, setFarms] = useState([]); + // Map from farm ID → its fields fetched from farm_fields table + const [fieldsByFarm, setFieldsByFarm] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [mapCenter, setMapCenter] = useState<[number, number]>([centerLat, centerLng]); @@ -74,40 +75,51 @@ const FarmMap: React.FC = ({ if (farmId) { const farm = await farmApi.getFarm(farmId); - if (farm) { - setFarms([farm]); - // Prefer centre from named fields, then legacy polygon centroid, then explicit lat/lng - const firstField = farm.location?.fields?.[0]; - if (firstField?.center_latitude && firstField?.center_longitude) { - setMapCenter([firstField.center_latitude, firstField.center_longitude]); - setMapZoom(16); - } else if (farm.location?.polygon && farm.location.polygon.length >= 3) { - const poly = farm.location.polygon; - setMapCenter([ - poly.reduce((s: number, p: { lat: number }) => s + p.lat, 0) / poly.length, - poly.reduce((s: number, p: { lng: number }) => s + p.lng, 0) / poly.length, - ]); - setMapZoom(16); - } else if (farm.location?.latitude && farm.location?.longitude) { - setMapCenter([farm.location.latitude, farm.location.longitude]); - setMapZoom(16); - } - } else { - setError('Farm not found'); + if (!farm) { setError('Farm not found'); return; } + + setFarms([farm]); + + // Load fields from the dedicated table + const farmFields: Field[] = await farmApi.getFarmFields(farmId).catch(() => []); + setFieldsByFarm({ [farmId]: farmFields }); + + // Set map centre from first field, then legacy polygon centroid, then lat/lng + const firstField = farmFields[0]; + if (firstField?.center_latitude && firstField?.center_longitude) { + setMapCenter([firstField.center_latitude, firstField.center_longitude]); + setMapZoom(16); + } else if (farm.location?.polygon && farm.location.polygon.length >= 3) { + const poly = farm.location.polygon; + setMapCenter([ + poly.reduce((s: number, p: { lat: number }) => s + p.lat, 0) / poly.length, + poly.reduce((s: number, p: { lng: number }) => s + p.lng, 0) / poly.length, + ]); + setMapZoom(16); + } else if (farm.location?.latitude && farm.location?.longitude) { + setMapCenter([farm.location.latitude, farm.location.longitude]); + setMapZoom(16); } } else if (showAllFarms) { const allFarms: Farm[] = await farmApi.getFarms(); const farmsWithData = allFarms.filter( f => - (f.location?.fields?.length ?? 0) > 0 || - (f.location?.polygon?.length ?? 0) >= 3 || - (f.location?.latitude && f.location?.longitude) + (f.location?.latitude && f.location?.longitude) || + (f.location?.polygon?.length ?? 0) >= 3 ); setFarms(farmsWithData); + // Load fields for each farm in parallel + const entries = await Promise.all( + farmsWithData.map(async f => { + const ff: Field[] = await farmApi.getFarmFields(f.id).catch(() => []); + return [f.id, ff] as [string, Field[]]; + }) + ); + setFieldsByFarm(Object.fromEntries(entries)); + if (farmsWithData.length > 0) { const first = farmsWithData[0]; - const firstField = first.location?.fields?.[0]; + const firstField = entries[0]?.[1]?.[0]; if (firstField?.center_latitude && firstField?.center_longitude) { setMapCenter([firstField.center_latitude, firstField.center_longitude]); } else if (first.location?.latitude && first.location?.longitude) { @@ -165,29 +177,28 @@ const FarmMap: React.FC = ({ /> {farms.map(farm => { - const fields: Field[] = farm.location?.fields ?? []; + const fields: Field[] = fieldsByFarm[farm.id] ?? []; const hasFields = fields.length > 0; const hasLegacyPolygon = - !hasFields && - (farm.location?.polygon?.length ?? 0) >= 3; + !hasFields && (farm.location?.polygon?.length ?? 0) >= 3; - // Marker position: first field centre → legacy polygon centroid → explicit lat/lng + // Marker position: first field centre → legacy polygon centroid → lat/lng const markerLat = fields[0]?.center_latitude ?? farm.location?.latitude ?? (hasLegacyPolygon - ? farm.location!.polygon!.reduce((s, p) => s + p.lat, 0) / farm.location!.polygon!.length + ? farm.location!.polygon!.reduce((s: number, p: { lat: number }) => s + p.lat, 0) / farm.location!.polygon!.length : undefined); const markerLng = fields[0]?.center_longitude ?? farm.location?.longitude ?? (hasLegacyPolygon - ? farm.location!.polygon!.reduce((s, p) => s + p.lng, 0) / farm.location!.polygon!.length + ? farm.location!.polygon!.reduce((s: number, p: { lng: number }) => s + p.lng, 0) / farm.location!.polygon!.length : undefined); return ( - {/* Named fields — each gets its own colour */} + {/* Named fields from farm_fields table — each gets its own colour */} {hasFields && fields.map((field, idx) => { if (!field.polygon || field.polygon.length < 3) return null; @@ -200,19 +211,23 @@ const FarmMap: React.FC = ({ >
-
{field.name}
+
+ {field.name} +
{field.area_acres.toFixed(2)} acres {field.area_hectares ? ` · ${field.area_hectares.toFixed(2)} ha` : ''}
-
{farm.name}
+
+ {farm.name} +
); })} - {/* Legacy single polygon — shown only if no named fields exist */} + {/* Legacy single polygon — only shown when no named fields exist */} {hasLegacyPolygon && ( [p.lat, p.lng] as [number, number])} From 4bbff6d8adccc79a4f7c9bfffa7924993b3d9c20 Mon Sep 17 00:00:00 2001 From: Slevin Cordeiro <112761840+Shade555@users.noreply.github.com> Date: Sat, 6 Jun 2026 20:33:14 +0530 Subject: [PATCH 9/9] fix: add missing comma in package.json causing lint error --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c3f0d3e..9b276de 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "prepare": "husky" }, "devDependencies": { - "supabase": "^2.105.0" + "supabase": "^2.105.0", "@commitlint/cli": "^21.0.2", "@commitlint/config-conventional": "^21.0.2", "concurrently": "^8.2.2",