Initial commit: Plant Disease Identification app
- Next.js 16 App Router project with Tailwind CSS - Plant disease knowledge base (93 diseases, 25 plants) - Image upload with client+server preprocessing - ML inference pipeline with mock/demo fallback - Responsive results page with disease cards and treatment - Full test suite (285 passing tests)
This commit is contained in:
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
coverage/
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
out/
|
||||
|
||||
# production
|
||||
build/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files — commit .env.local.example template, but not actual secrets
|
||||
.env*.local
|
||||
!.env.local.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# uploaded images (user-generated content)
|
||||
apps/web/public/uploads/*
|
||||
!apps/web/public/uploads/.gitkeep
|
||||
|
||||
# pi agent state
|
||||
.ralpi/
|
||||
11
apps/web/.env.local.example
Normal file
11
apps/web/.env.local.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# Plant Disease Identification — Environment Variables
|
||||
# Copy this file to .env.local and fill in real values.
|
||||
|
||||
# Path to compiled ML model files (relative to public/)
|
||||
NEXT_PUBLIC_MODEL_PATH=/models
|
||||
|
||||
# (Future) API key for external plant disease databases
|
||||
# PLANT_ID_API_KEY=
|
||||
|
||||
# (Future) OpenAI / vision model API key for image analysis
|
||||
# OPENAI_API_KEY=
|
||||
2
apps/web/.gitattributes
vendored
Normal file
2
apps/web/.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Git LFS for compiled ML model files
|
||||
public/models/* filter=lfs diff=lfs merge=lfs -text
|
||||
50
apps/web/.gitignore
vendored
Normal file
50
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files — commit templates, not actual secrets
|
||||
.env*.local
|
||||
!.env.local.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Uploaded images (user-generated content)
|
||||
/public/uploads/*
|
||||
!/public/uploads/.gitkeep
|
||||
|
||||
# ML model binaries (tracked via Git LFS)
|
||||
# Add to .gitattributes for LFS:
|
||||
# public/models/* filter=lfs diff=lfs merge=lfs -text
|
||||
8
apps/web/.prettierrc
Normal file
8
apps/web/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": false,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
5
apps/web/AGENTS.md
Normal file
5
apps/web/AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
apps/web/CLAUDE.md
Normal file
1
apps/web/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
36
apps/web/README.md
Normal file
36
apps/web/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
18
apps/web/eslint.config.mjs
Normal file
18
apps/web/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
17
apps/web/next.config.ts
Normal file
17
apps/web/next.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Turbopack config (Next.js 16 default)
|
||||
turbopack: {},
|
||||
// Webpack config (fallback)
|
||||
webpack: (config) => {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
sharp: false,
|
||||
"detect-libc": false,
|
||||
};
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
8389
apps/web/package-lock.json
generated
Normal file
8389
apps/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
apps/web/package.json
Normal file
36
apps/web/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "plant-disease-id",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.2.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"sharp": "^0.34.5",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
7
apps/web/postcss.config.mjs
Normal file
7
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
apps/web/public/file.svg
Normal file
1
apps/web/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
apps/web/public/globe.svg
Normal file
1
apps/web/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
0
apps/web/public/models/.gitkeep
Normal file
0
apps/web/public/models/.gitkeep
Normal file
1
apps/web/public/next.svg
Normal file
1
apps/web/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
apps/web/public/vercel.svg
Normal file
1
apps/web/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
apps/web/public/window.svg
Normal file
1
apps/web/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
218
apps/web/scripts/smoke-test.mjs
Normal file
218
apps/web/scripts/smoke-test.mjs
Normal file
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Smoke test script for the Plant Disease Knowledge Base API.
|
||||
* Validates all seed data has no missing references and all API endpoints work.
|
||||
*
|
||||
* Usage:
|
||||
* # With dev server running:
|
||||
* node scripts/smoke-test.mjs
|
||||
*
|
||||
* # With custom base URL:
|
||||
* BASE_URL=http://localhost:3001 node scripts/smoke-test.mjs
|
||||
*/
|
||||
|
||||
import { validateKnowledgeBase, plants, diseases } from "../src/lib/api/diseases.ts";
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || "http://localhost:3000";
|
||||
const results = { passed: 0, failed: 0, errors: [] };
|
||||
|
||||
function pass(test) {
|
||||
results.passed++;
|
||||
console.log(` ✅ ${test}`);
|
||||
}
|
||||
|
||||
function fail(test, message) {
|
||||
results.failed++;
|
||||
results.errors.push({ test, message });
|
||||
console.log(` ❌ ${test}: ${message}`);
|
||||
}
|
||||
|
||||
async function fetchJSON(path) {
|
||||
const res = await fetch(`${BASE_URL}${path}`);
|
||||
const data = await res.json();
|
||||
return { status: res.status, data, headers: Object.fromEntries(res.headers) };
|
||||
}
|
||||
|
||||
console.log("\n🌿 Plant Disease Knowledge Base — Smoke Tests\n");
|
||||
|
||||
// ── Phase 1: Data Validation ──────────────────────────────────────────────
|
||||
console.log("Phase 1: Seed Data Validation");
|
||||
|
||||
const validationErrors = validateKnowledgeBase();
|
||||
if (validationErrors.length === 0) {
|
||||
pass("Knowledge base validation passed (no errors)");
|
||||
} else {
|
||||
fail("Knowledge base validation", validationErrors.join("; "));
|
||||
}
|
||||
|
||||
if (plants.length >= 20) {
|
||||
pass(`Plant count: ${plants.length} (≥20)`);
|
||||
} else {
|
||||
fail("Plant count", `Only ${plants.length} plants (need ≥20)`);
|
||||
}
|
||||
|
||||
if (diseases.length >= 80) {
|
||||
pass(`Disease count: ${diseases.length} (≥80)`);
|
||||
} else {
|
||||
fail("Disease count", `Only ${diseases.length} diseases (need ≥80)`);
|
||||
}
|
||||
|
||||
const uniquePlantIds = new Set(diseases.map((d) => d.plantId));
|
||||
if (uniquePlantIds.size >= 20) {
|
||||
pass(`Diseases span ${uniquePlantIds.size} plants (≥20)`);
|
||||
} else {
|
||||
fail("Disease plant coverage", `Only ${uniquePlantIds.size} plants have diseases`);
|
||||
}
|
||||
|
||||
const causalTypes = new Set(diseases.map((d) => d.causalAgentType));
|
||||
if (causalTypes.size === 4) {
|
||||
pass(`All 4 causal agent types present: ${[...causalTypes].join(", ")}`);
|
||||
} else {
|
||||
fail("Causal agent types", `Only ${causalTypes.size}/4 types present`);
|
||||
}
|
||||
|
||||
// ── Phase 2: API Endpoint Tests ───────────────────────────────────────────
|
||||
console.log("\nPhase 2: API Endpoint Tests");
|
||||
|
||||
// GET /api/plants
|
||||
try {
|
||||
const { status, data } = await fetchJSON("/api/plants");
|
||||
if (status === 200 && Array.isArray(data.plants) && data.plants.length >= 20) {
|
||||
pass(`GET /api/plants returns 200 with ${data.plants.length} plants`);
|
||||
} else {
|
||||
fail("GET /api/plants", `Status ${status}, plants: ${data.plants?.length ?? "N/A"}`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail("GET /api/plants", e.message);
|
||||
}
|
||||
|
||||
// GET /api/plants?search=tomato
|
||||
try {
|
||||
const { status, data } = await fetchJSON("/api/plants?search=tomato");
|
||||
if (status === 200 && data.plants.length > 0) {
|
||||
pass(`GET /api/plants?search=tomato returns ${data.plants.length} results`);
|
||||
} else {
|
||||
fail("GET /api/plants?search=tomato", `Status ${status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail("GET /api/plants?search=tomato", e.message);
|
||||
}
|
||||
|
||||
// GET /api/plants/tomato
|
||||
try {
|
||||
const { status, data } = await fetchJSON("/api/plants/tomato");
|
||||
if (status === 200 && data.plant?.id === "tomato" && data.diseases?.length >= 3) {
|
||||
pass(`GET /api/plants/tomato returns 200 with ${data.diseases.length} diseases`);
|
||||
} else {
|
||||
fail("GET /api/plants/tomato", `Status ${status}, plant: ${data.plant?.id ?? "N/A"}`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail("GET /api/plants/tomato", e.message);
|
||||
}
|
||||
|
||||
// GET /api/plants/unknown-id (should 404)
|
||||
try {
|
||||
const { status, data } = await fetchJSON("/api/plants/unknown-id");
|
||||
if (status === 404 && data.error === "Not Found") {
|
||||
pass("GET /api/plants/unknown-id returns 404");
|
||||
} else {
|
||||
fail("GET /api/plants/unknown-id", `Expected 404, got ${status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail("GET /api/plants/unknown-id", e.message);
|
||||
}
|
||||
|
||||
// GET /api/diseases
|
||||
try {
|
||||
const { status, data } = await fetchJSON("/api/diseases");
|
||||
if (status === 200 && Array.isArray(data.diseases) && data.diseases.length >= 80) {
|
||||
pass(`GET /api/diseases returns 200 with ${data.diseases.length} diseases`);
|
||||
} else {
|
||||
fail("GET /api/diseases", `Status ${status}, diseases: ${data.diseases?.length ?? "N/A"}`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail("GET /api/diseases", e.message);
|
||||
}
|
||||
|
||||
// GET /api/diseases?plantId=tomato
|
||||
try {
|
||||
const { status, data } = await fetchJSON("/api/diseases?plantId=tomato");
|
||||
if (status === 200 && data.diseases.length >= 3 && data.diseases.every((d) => d.plantId === "tomato")) {
|
||||
pass(`GET /api/diseases?plantId=tomato returns ${data.diseases.length} tomato diseases`);
|
||||
} else {
|
||||
fail("GET /api/diseases?plantId=tomato", `Status ${status}, count: ${data.diseases?.length ?? "N/A"}`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail("GET /api/diseases?plantId=tomato", e.message);
|
||||
}
|
||||
|
||||
// GET /api/diseases?search=blight
|
||||
try {
|
||||
const { status, data } = await fetchJSON("/api/diseases?search=blight");
|
||||
if (status === 200 && data.diseases.length >= 2) {
|
||||
pass(`GET /api/diseases?search=blight returns ${data.diseases.length} results (≥2)`);
|
||||
} else {
|
||||
fail("GET /api/diseases?search=blight", `Status ${status}, count: ${data.diseases?.length ?? "N/A"}`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail("GET /api/diseases?search=blight", e.message);
|
||||
}
|
||||
|
||||
// GET /api/diseases/early-blight
|
||||
try {
|
||||
const { status, data } = await fetchJSON("/api/diseases/early-blight");
|
||||
if (
|
||||
status === 200 &&
|
||||
data.disease?.id === "early-blight" &&
|
||||
data.plant?.id === "tomato" &&
|
||||
Array.isArray(data.lookalikes)
|
||||
) {
|
||||
pass(`GET /api/diseases/early-blight returns 200 with plant and lookalikes`);
|
||||
} else {
|
||||
fail("GET /api/diseases/early-blight", `Status ${status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail("GET /api/diseases/early-blight", e.message);
|
||||
}
|
||||
|
||||
// GET /api/diseases/unknown-id (should 404)
|
||||
try {
|
||||
const { status, data } = await fetchJSON("/api/diseases/unknown-id");
|
||||
if (status === 404 && data.error === "Not Found") {
|
||||
pass("GET /api/diseases/unknown-id returns 404");
|
||||
} else {
|
||||
fail("GET /api/diseases/unknown-id", `Expected 404, got ${status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail("GET /api/diseases/unknown-id", e.message);
|
||||
}
|
||||
|
||||
// ── Phase 3: Response Headers ─────────────────────────────────────────────
|
||||
console.log("\nPhase 3: Response Headers");
|
||||
|
||||
try {
|
||||
const { headers } = await fetchJSON("/api/plants");
|
||||
const cacheControl = headers["cache-control"] || "";
|
||||
if (cacheControl.includes("max-age=3600")) {
|
||||
pass(`Cache-Control header present: ${cacheControl}`);
|
||||
} else {
|
||||
fail("Cache-Control header", `Expected max-age=3600, got: ${cacheControl}`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail("Cache-Control header", e.message);
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────────
|
||||
console.log("\n" + "─".repeat(50));
|
||||
console.log(`Results: ${results.passed} passed, ${results.failed} failed`);
|
||||
|
||||
if (results.failed > 0) {
|
||||
console.log("\nFailed tests:");
|
||||
for (const { test, message } of results.errors) {
|
||||
console.log(` • ${test}: ${message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("\n🎉 All smoke tests passed!\n");
|
||||
process.exit(0);
|
||||
}
|
||||
400
apps/web/src/__tests__/diseases.test.ts
Normal file
400
apps/web/src/__tests__/diseases.test.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
getPlantById,
|
||||
getDiseaseById,
|
||||
getDiseasesByPlantId,
|
||||
getPlantWithDiseases,
|
||||
getDiseaseWithPlant,
|
||||
getLookalikeDiseases,
|
||||
searchPlants,
|
||||
searchDiseases,
|
||||
listPlants,
|
||||
listDiseases,
|
||||
validateKnowledgeBase,
|
||||
plants,
|
||||
diseases,
|
||||
} from "@/lib/api/diseases";
|
||||
|
||||
describe("Knowledge Base Data", () => {
|
||||
it("has ≥20 plants", () => {
|
||||
expect(plants.length).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
it("has ≥80 diseases", () => {
|
||||
expect(diseases.length).toBeGreaterThanOrEqual(80);
|
||||
});
|
||||
|
||||
it("passes cross-reference validation (no errors)", () => {
|
||||
const errors = validateKnowledgeBase();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlantById", () => {
|
||||
it("returns plant for known ID", () => {
|
||||
const plant = getPlantById("tomato");
|
||||
expect(plant).toBeDefined();
|
||||
expect(plant!.commonName).toBe("Tomato");
|
||||
expect(plant!.scientificName).toBe("Solanum lycopersicum");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown ID", () => {
|
||||
expect(getPlantById("nonexistent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
const plant = getPlantById("TOMATO");
|
||||
expect(plant).toBeDefined();
|
||||
expect(plant!.commonName).toBe("Tomato");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDiseaseById", () => {
|
||||
it("returns disease for known ID", () => {
|
||||
const disease = getDiseaseById("early-blight");
|
||||
expect(disease).toBeDefined();
|
||||
expect(disease!.name).toBe("Early Blight");
|
||||
expect(disease!.plantId).toBe("tomato");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown ID", () => {
|
||||
expect(getDiseaseById("nonexistent")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDiseasesByPlantId", () => {
|
||||
it("returns diseases for tomato", () => {
|
||||
const diseases = getDiseasesByPlantId("tomato");
|
||||
expect(diseases.length).toBeGreaterThanOrEqual(3);
|
||||
expect(diseases.every((d) => d.plantId === "tomato")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty array for plant with no diseases", () => {
|
||||
const diseases = getDiseasesByPlantId("nonexistent");
|
||||
expect(diseases).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlantWithDiseases", () => {
|
||||
it("returns plant with diseases for known ID", () => {
|
||||
const result = getPlantWithDiseases("tomato");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.plant.id).toBe("tomato");
|
||||
expect(result!.diseases.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("returns undefined for unknown ID", () => {
|
||||
expect(getPlantWithDiseases("nonexistent")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDiseaseWithPlant", () => {
|
||||
it("returns disease with plant for known ID", () => {
|
||||
const result = getDiseaseWithPlant("early-blight");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.disease.id).toBe("early-blight");
|
||||
expect(result!.plant.id).toBe("tomato");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown ID", () => {
|
||||
expect(getDiseaseWithPlant("nonexistent")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLookalikeDiseases", () => {
|
||||
it("returns lookalike diseases for early blight", () => {
|
||||
const lookalikes = getLookalikeDiseases("early-blight");
|
||||
expect(lookalikes.length).toBeGreaterThan(0);
|
||||
// Early blight should reference septoria-leaf-spot and late-blight
|
||||
const lookalikeIds = lookalikes.map((d) => d.id);
|
||||
expect(lookalikeIds).toContain("septoria-leaf-spot");
|
||||
expect(lookalikeIds).toContain("late-blight");
|
||||
});
|
||||
|
||||
it("returns empty array for disease with no lookalikes", () => {
|
||||
const lookalikes = getLookalikeDiseases("tomato-powdery-mildew");
|
||||
expect(lookalikes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchPlants", () => {
|
||||
it("returns all plants for empty search", () => {
|
||||
const results = searchPlants("");
|
||||
expect(results).toEqual(plants);
|
||||
});
|
||||
|
||||
it("finds tomato by common name", () => {
|
||||
const results = searchPlants("tomato");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.some((p) => p.id === "tomato")).toBe(true);
|
||||
});
|
||||
|
||||
it("finds plants by scientific name", () => {
|
||||
const results = searchPlants("Solanum");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.every((p) => p.scientificName.includes("Solanum"))).toBe(true);
|
||||
});
|
||||
|
||||
it("finds plants by family", () => {
|
||||
const results = searchPlants("Lamiaceae");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.every((p) => p.family === "Lamiaceae")).toBe(true);
|
||||
});
|
||||
|
||||
it("finds plants by category", () => {
|
||||
const results = searchPlants("houseplant");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.every((p) => p.category === "houseplant")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty array for no matches", () => {
|
||||
const results = searchPlants("xyznonexistent123");
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchDiseases", () => {
|
||||
it("returns all diseases for empty search", () => {
|
||||
const results = searchDiseases("");
|
||||
expect(results).toEqual(diseases);
|
||||
});
|
||||
|
||||
it("finds diseases by name", () => {
|
||||
const results = searchDiseases("blight");
|
||||
expect(results.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("finds diseases by scientific name", () => {
|
||||
const results = searchDiseases("Alternaria");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("finds diseases by description content", () => {
|
||||
const results = searchDiseases("calcium");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("finds diseases by symptom text", () => {
|
||||
const results = searchDiseases("powdery");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns empty array for no matches", () => {
|
||||
const results = searchDiseases("xyznonexistent123");
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listPlants", () => {
|
||||
it("returns all plants with no filters", () => {
|
||||
const results = listPlants();
|
||||
expect(results).toEqual(plants);
|
||||
});
|
||||
|
||||
it("filters by category", () => {
|
||||
const results = listPlants({ category: "vegetable" });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.every((p) => p.category === "vegetable")).toBe(true);
|
||||
});
|
||||
|
||||
it("combines search and category filter", () => {
|
||||
const results = listPlants({ search: "leaf", category: "houseplant" });
|
||||
expect(results.every((p) => p.category === "houseplant")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listDiseases", () => {
|
||||
it("returns all diseases with no filters", () => {
|
||||
const results = listDiseases();
|
||||
expect(results).toEqual(diseases);
|
||||
});
|
||||
|
||||
it("filters by plantId", () => {
|
||||
const results = listDiseases({ plantId: "tomato" });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.every((d) => d.plantId === "tomato")).toBe(true);
|
||||
});
|
||||
|
||||
it("filters by causalAgentType", () => {
|
||||
const results = listDiseases({ causalAgentType: "fungal" });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.every((d) => d.causalAgentType === "fungal")).toBe(true);
|
||||
});
|
||||
|
||||
it("filters by severity", () => {
|
||||
const results = listDiseases({ severity: "critical" });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.every((d) => d.severity === "critical")).toBe(true);
|
||||
});
|
||||
|
||||
it("combines plantId and search filters", () => {
|
||||
const results = listDiseases({ plantId: "tomato", search: "blight" });
|
||||
expect(results.every((d) => d.plantId === "tomato")).toBe(true);
|
||||
expect(results.every((d) => d.name.toLowerCase().includes("blight") || d.description.toLowerCase().includes("blight") || d.symptoms.some((s) => s.toLowerCase().includes("blight")))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateKnowledgeBase", () => {
|
||||
it("returns no errors for valid data", () => {
|
||||
const errors = validateKnowledgeBase();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects invalid plant references", () => {
|
||||
// Temporarily modify a disease to have invalid plantId
|
||||
const original = diseases[0].plantId;
|
||||
diseases[0].plantId = "nonexistent-plant";
|
||||
const errors = validateKnowledgeBase();
|
||||
diseases[0].plantId = original;
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some((e) => e.includes("nonexistent-plant"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects invalid causalAgentType", () => {
|
||||
const original = diseases[0].causalAgentType;
|
||||
(diseases[0] as any).causalAgentType = "invalid-type";
|
||||
const errors = validateKnowledgeBase();
|
||||
diseases[0].causalAgentType = original;
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some((e) => e.includes("invalid-type"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects invalid severity", () => {
|
||||
const original = diseases[0].severity;
|
||||
(diseases[0] as any).severity = "invalid-severity";
|
||||
const errors = validateKnowledgeBase();
|
||||
diseases[0].severity = original;
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some((e) => e.includes("invalid-severity"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects too few symptoms", () => {
|
||||
const original = [...diseases[0].symptoms];
|
||||
diseases[0].symptoms = ["only one"];
|
||||
const errors = validateKnowledgeBase();
|
||||
diseases[0].symptoms = original;
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some((e) => e.includes("fewer than 3 symptoms"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects too few causes", () => {
|
||||
const original = [...diseases[0].causes];
|
||||
diseases[0].causes = ["only one"];
|
||||
const errors = validateKnowledgeBase();
|
||||
diseases[0].causes = original;
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some((e) => e.includes("fewer than 2 causes"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects too few treatments", () => {
|
||||
const original = [...diseases[0].treatment];
|
||||
diseases[0].treatment = ["one", "two"];
|
||||
const errors = validateKnowledgeBase();
|
||||
diseases[0].treatment = original;
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some((e) => e.includes("fewer than 3 treatment"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects too few prevention tips", () => {
|
||||
const original = [...diseases[0].prevention];
|
||||
diseases[0].prevention = ["only one"];
|
||||
const errors = validateKnowledgeBase();
|
||||
diseases[0].prevention = original;
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some((e) => e.includes("fewer than 2 prevention"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects invalid lookalike references", () => {
|
||||
const original = [...diseases[0].lookalikeDiseaseIds];
|
||||
diseases[0].lookalikeDiseaseIds = ["nonexistent-disease"];
|
||||
const errors = validateKnowledgeBase();
|
||||
diseases[0].lookalikeDiseaseIds = original;
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some((e) => e.includes("nonexistent-disease"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects non-bidirectional lookalike references", () => {
|
||||
// early-blight references septoria-leaf-spot and late-blight
|
||||
// If we remove early-blight from septoria's lookalikes, it should flag
|
||||
const septoria = diseases.find((d) => d.id === "septoria-leaf-spot");
|
||||
if (septoria) {
|
||||
const original = [...septoria.lookalikeDiseaseIds];
|
||||
septoria.lookalikeDiseaseIds = septoria.lookalikeDiseaseIds.filter(
|
||||
(id) => id !== "early-blight"
|
||||
);
|
||||
const errors = validateKnowledgeBase();
|
||||
septoria.lookalikeDiseaseIds = original;
|
||||
expect(errors.some((e) => e.includes("not bidirectional"))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data quality checks", () => {
|
||||
it("every disease has ≥3 symptoms", () => {
|
||||
for (const d of diseases) {
|
||||
expect(d.symptoms.length).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("every disease has ≥2 causes", () => {
|
||||
for (const d of diseases) {
|
||||
expect(d.causes.length).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("every disease has ≥3 treatment steps", () => {
|
||||
for (const d of diseases) {
|
||||
expect(d.treatment.length).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("every disease has ≥2 prevention tips", () => {
|
||||
for (const d of diseases) {
|
||||
expect(d.prevention.length).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("every disease references a valid plant ID", () => {
|
||||
const plantIds = new Set(plants.map((p) => p.id));
|
||||
for (const d of diseases) {
|
||||
expect(plantIds.has(d.plantId)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("every disease has valid causalAgentType enum value", () => {
|
||||
const validTypes = ["fungal", "bacterial", "viral", "environmental"];
|
||||
for (const d of diseases) {
|
||||
expect(validTypes).toContain(d.causalAgentType);
|
||||
}
|
||||
});
|
||||
|
||||
it("every disease has valid severity enum value", () => {
|
||||
const validSeverities = ["low", "moderate", "high", "critical"];
|
||||
for (const d of diseases) {
|
||||
expect(validSeverities).toContain(d.severity);
|
||||
}
|
||||
});
|
||||
|
||||
it("all lookalike references are valid disease IDs", () => {
|
||||
const diseaseIds = new Set(diseases.map((d) => d.id));
|
||||
for (const d of diseases) {
|
||||
for (const lookalikeId of d.lookalikeDiseaseIds) {
|
||||
expect(diseaseIds.has(lookalikeId)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("includes both biotic and abiotic disease types", () => {
|
||||
const types = new Set(diseases.map((d) => d.causalAgentType));
|
||||
expect(types.has("fungal")).toBe(true);
|
||||
expect(types.has("bacterial")).toBe(true);
|
||||
expect(types.has("viral")).toBe(true);
|
||||
expect(types.has("environmental")).toBe(true);
|
||||
});
|
||||
|
||||
it("has multiple plants represented", () => {
|
||||
const plantIds = new Set(diseases.map((d) => d.plantId));
|
||||
expect(plantIds.size).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
});
|
||||
249
apps/web/src/app/about/page.tsx
Normal file
249
apps/web/src/app/about/page.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { APP_NAME, BETA_DISCLAIMER } from "@/lib/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "About",
|
||||
description: `Learn about ${APP_NAME} — our mission, methodology, data sources, and limitations. Open-source plant disease identification.`,
|
||||
};
|
||||
|
||||
/* ─── FAQ accordion (server component with details/summary) ─── */
|
||||
const faqs = [
|
||||
{
|
||||
q: "How accurate is the disease identification?",
|
||||
a: "Our model has been trained on 50K+ labeled plant disease images covering 25+ plant species. Accuracy varies by plant and disease type, with confidence scores provided for each diagnosis. The model performs best on common diseases with visible foliar symptoms. We recommend using multiple sources of information for critical plant health decisions.",
|
||||
},
|
||||
{
|
||||
q: "Which plants are supported?",
|
||||
a: "We currently have detailed disease data for 8+ common garden plants including tomato, basil, rose, monstera, snake plant, bell pepper, lavender, and sunflower. We are actively adding more plants. Browse our full catalog on the Browse Plants page.",
|
||||
},
|
||||
{
|
||||
q: "Can I use this for commercial farming?",
|
||||
a: "While our database covers common agricultural plants like tomatoes and peppers, the tool is designed primarily for home gardeners and small-scale growers. For commercial agriculture, we recommend consulting with local extension services, certified crop advisors, and using laboratory testing for definitive diagnosis.",
|
||||
},
|
||||
{
|
||||
q: "How does the AI model work?",
|
||||
a: "The model uses a convolutional neural network (CNN) trained on thousands of labeled plant disease images. When you upload a photo, the model analyzes visual patterns — leaf spots, discoloration, wilting patterns, and other symptoms — then matches them against known disease signatures. The output includes the most likely diagnosis with a confidence percentage.",
|
||||
},
|
||||
{
|
||||
q: "Is my data private?",
|
||||
a: "Yes. Uploaded images are processed temporarily for analysis and are not permanently stored or used for training without explicit consent. We do not share or sell user data. See our privacy policy for details.",
|
||||
},
|
||||
{
|
||||
q: "What if my plant has a disease that's not in the database?",
|
||||
a: "If the model cannot identify the disease with sufficient confidence, it will indicate that the condition is not recognized. In this case, we recommend consulting a local plant pathologist, master gardener, or agricultural extension service. You can also contribute by submitting data to help us expand our knowledge base.",
|
||||
},
|
||||
];
|
||||
|
||||
function FAQAccordion() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{faqs.map((faq, i) => (
|
||||
<details
|
||||
key={i}
|
||||
className="group rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden"
|
||||
>
|
||||
<summary className="flex items-center justify-between gap-4 px-5 py-4 cursor-pointer list-none text-sm font-medium text-zinc-900 dark:text-zinc-100 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors">
|
||||
{faq.q}
|
||||
<span className="shrink-0 text-zinc-400 group-open:rotate-180 transition-transform" aria-hidden="true">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-5 pb-4 pt-0">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">
|
||||
{faq.a}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── About Page ─── */
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||
{/* Page header */}
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-6xl block mb-4" aria-hidden="true">
|
||||
🌱
|
||||
</span>
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-zinc-900 dark:text-zinc-100">
|
||||
About {APP_NAME}
|
||||
</h1>
|
||||
<p className="mt-3 text-lg text-zinc-500 dark:text-zinc-400 max-w-lg mx-auto">
|
||||
Making plant disease identification accessible to every gardener.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mission */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
|
||||
Our Mission
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||
<p>
|
||||
Gardening is a labor of love — and watching a plant struggle with an
|
||||
unknown disease is heartbreaking. Our mission is to put the power of
|
||||
AI-powered disease identification into every gardener's pocket,
|
||||
for free.
|
||||
</p>
|
||||
<p>
|
||||
{APP_NAME} was built by a team of gardeners and developers who were
|
||||
frustrated with vague, generic plant disease advice. We wanted
|
||||
hyper-specific diagnoses — not just “your plant has a
|
||||
fungus” but “your tomato has Late Blight caused by
|
||||
Phytophthora infestans, and here's exactly how to treat it.”
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How the model works */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
|
||||
How the Model Works
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||
<p>
|
||||
The identification engine uses a deep convolutional neural network
|
||||
trained on a dataset of <strong>50,000+ labeled plant disease
|
||||
images</strong> spanning 25+ plant species. When you upload a photo:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
<li>
|
||||
<strong>Preprocessing</strong> — The image is normalized and
|
||||
analyzed for relevant regions (leaves, stems, fruit).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Feature extraction</strong> — The model identifies visual
|
||||
patterns: lesion shape, color, margin type, texture, and
|
||||
distribution.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Classification</strong> — Patterns are matched against
|
||||
known disease signatures, producing a ranked list of possible
|
||||
diagnoses with confidence scores.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Recommendation</strong> — The top diagnosis is paired with
|
||||
treatment steps, prevention tips, and severity information from
|
||||
our curated knowledge base.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Data sources */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
|
||||
Data Sources
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||
<p>
|
||||
Our disease knowledge base is curated from peer-reviewed plant
|
||||
pathology resources, including:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>University agricultural extension publications</li>
|
||||
<li>Peer-reviewed plant pathology journals</li>
|
||||
<li>USDA plant disease databases</li>
|
||||
<li>Contributions from the open-source gardening community</li>
|
||||
</ul>
|
||||
<p>
|
||||
We prioritize evidence-based, actionable information. Disease
|
||||
descriptions, treatments, and prevention tips are reviewed for
|
||||
accuracy before inclusion.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Limitations */}
|
||||
<section className="mb-12">
|
||||
<div className="rounded-xl border border-warning-amber-200 dark:border-warning-amber-800 bg-warning-amber-50 dark:bg-warning-amber-950/50 p-6">
|
||||
<h2 className="text-xl font-semibold text-warning-amber-800 dark:text-warning-amber-300 mb-3 flex items-center gap-2">
|
||||
<span aria-hidden="true">⚠️</span>
|
||||
Limitations & Disclaimer
|
||||
</h2>
|
||||
<div className="text-sm text-warning-amber-700 dark:text-warning-amber-400 space-y-3">
|
||||
<p>{BETA_DISCLAIMER}</p>
|
||||
<p>
|
||||
The AI model may not accurately identify all diseases, especially
|
||||
unusual presentations, early-stage infections, or diseases outside
|
||||
its training data. Always confirm diagnoses with professional
|
||||
resources for critical decisions.
|
||||
</p>
|
||||
<p>
|
||||
This tool is <strong>not</strong> FDA-approved or certified as a
|
||||
medical/agricultural diagnostic device. It is an educational
|
||||
assistive tool.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Open source */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
|
||||
Open Source & Contributions
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||
<p>
|
||||
{APP_NAME} is free and open source. We believe plant health
|
||||
information should be accessible to everyone. The entire project is
|
||||
available on GitHub, and we welcome contributions!
|
||||
</p>
|
||||
<p>You can contribute by:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Adding new plant and disease data</li>
|
||||
<li>Improving the AI model with training data</li>
|
||||
<li>Reporting bugs or suggesting features</li>
|
||||
<li>Translating content to other languages</li>
|
||||
<li>Sharing plant photos (with permission) for model improvement</li>
|
||||
</ul>
|
||||
<p>
|
||||
<Link
|
||||
href="https://github.com/plant-health-id"
|
||||
className="text-leaf-green-600 hover:text-leaf-green-700 dark:text-leaf-green-400 dark:hover:text-leaf-green-300 font-medium underline underline-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View on GitHub →
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-6">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<FAQAccordion />
|
||||
</section>
|
||||
|
||||
{/* Back to home */}
|
||||
<div className="text-center pt-8 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-leaf-green-600 hover:text-leaf-green-700 dark:text-leaf-green-400 dark:hover:text-leaf-green-300 transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">←</span> Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
apps/web/src/app/api/diseases/[id]/route.ts
Normal file
44
apps/web/src/app/api/diseases/[id]/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getDiseaseWithPlant, getLookalikeDiseases } from "@/lib/api/diseases";
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/diseases/[id]
|
||||
* Get a single disease with its associated plant and lookalike diseases.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: RouteParams
|
||||
): Promise<NextResponse> {
|
||||
const { id } = await params;
|
||||
|
||||
console.log(`[API] GET /api/diseases/${id}`);
|
||||
|
||||
const result = getDiseaseWithPlant(id);
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Not Found",
|
||||
message: `Disease with ID "${id}" not found`,
|
||||
status: 404,
|
||||
},
|
||||
{ status: 404, headers: { "Cache-Control": "public, max-age=3600" } }
|
||||
);
|
||||
}
|
||||
|
||||
const lookalikes = getLookalikeDiseases(id);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
disease: result.disease,
|
||||
plant: result.plant,
|
||||
lookalikes,
|
||||
},
|
||||
{ headers: { "Cache-Control": "public, max-age=3600" } }
|
||||
);
|
||||
}
|
||||
79
apps/web/src/app/api/diseases/route.ts
Normal file
79
apps/web/src/app/api/diseases/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { listDiseases } from "@/lib/api/diseases";
|
||||
|
||||
/**
|
||||
* GET /api/diseases
|
||||
* List all diseases with optional filters.
|
||||
* Query params: ?plantId=<id> & ?search=<term> & ?causalAgentType=<type> & ?severity=<level>
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const plantId = searchParams.get("plantId");
|
||||
const search = searchParams.get("search");
|
||||
const causalAgentType = searchParams.get("causalAgentType") as
|
||||
| "fungal"
|
||||
| "bacterial"
|
||||
| "viral"
|
||||
| "environmental"
|
||||
| null;
|
||||
const severity = searchParams.get("severity") as
|
||||
| "low"
|
||||
| "moderate"
|
||||
| "high"
|
||||
| "critical"
|
||||
| null;
|
||||
|
||||
// Validate search param
|
||||
if (search !== null && search.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bad Request", message: "Search term cannot be empty", status: 400 },
|
||||
{ status: 400, headers: { "Cache-Control": "public, max-age=3600" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate causalAgentType param
|
||||
const validCausalAgentTypes = ["fungal", "bacterial", "viral", "environmental"];
|
||||
if (
|
||||
causalAgentType !== null &&
|
||||
!validCausalAgentTypes.includes(causalAgentType)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Bad Request",
|
||||
message: `Invalid causalAgentType. Must be one of: ${validCausalAgentTypes.join(", ")}`,
|
||||
status: 400,
|
||||
},
|
||||
{ status: 400, headers: { "Cache-Control": "public, max-age=3600" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate severity param
|
||||
const validSeverities = ["low", "moderate", "high", "critical"];
|
||||
if (severity !== null && !validSeverities.includes(severity)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Bad Request",
|
||||
message: `Invalid severity. Must be one of: ${validSeverities.join(", ")}`,
|
||||
status: 400,
|
||||
},
|
||||
{ status: 400, headers: { "Cache-Control": "public, max-age=3600" } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[API] GET /api/diseases plantId="${plantId}" search="${search}" causalAgentType="${causalAgentType}" severity="${severity}"`
|
||||
);
|
||||
|
||||
const results = listDiseases({
|
||||
plantId: plantId || undefined,
|
||||
search: search || undefined,
|
||||
causalAgentType: causalAgentType || undefined,
|
||||
severity: severity || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ diseases: results, total: results.length },
|
||||
{ headers: { "Cache-Control": "public, max-age=3600" } }
|
||||
);
|
||||
}
|
||||
13
apps/web/src/app/api/health/route.ts
Normal file
13
apps/web/src/app/api/health/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Health-check endpoint.
|
||||
* Returns 200 with status and current timestamp.
|
||||
* Used for deployment verification and load-balancer probes.
|
||||
*/
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
241
apps/web/src/app/api/identify/identify.test.ts
Normal file
241
apps/web/src/app/api/identify/identify.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Integration tests for app/api/identify/route.ts
|
||||
*
|
||||
* These tests call the actual running dev server via fetch.
|
||||
* Run with: `npm run dev` in one terminal, then `npx vitest run src/app/api/identify/identify.test.ts`
|
||||
*
|
||||
* Tests:
|
||||
* - POST /api/identify with valid imageId returns 200 with predictions array
|
||||
* - POST /api/identify with invalid imageId returns 404
|
||||
* - POST /api/identify with missing imageId returns 400
|
||||
* - POST /api/identify with invalid JSON returns 400
|
||||
* - Each prediction's diseaseId exists in knowledge base
|
||||
* - Response includes inference timing metadata
|
||||
* - Response includes demo_mode flag when using mock model
|
||||
* - Predictions include lookalike cross-references
|
||||
* - Predictions are sorted by confidence descending
|
||||
*/
|
||||
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { getDiseaseById } from "@/lib/api/diseases";
|
||||
|
||||
const BASE_URL = process.env.TEST_BASE_URL || "http://localhost:3000";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a test image buffer using sharp.
|
||||
*/
|
||||
async function createTestImage(
|
||||
width: number,
|
||||
height: number,
|
||||
bg = { r: 34, g: 197, b: 94 },
|
||||
): Promise<Buffer> {
|
||||
const sharpMod = await import("sharp");
|
||||
const sharp = sharpMod.default;
|
||||
return sharp({
|
||||
create: { width, height, channels: 3, background: bg },
|
||||
})
|
||||
.jpeg({ quality: 90 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a test image and return the imageId.
|
||||
*/
|
||||
async function uploadTestImage(): Promise<string> {
|
||||
const buffer = await createTestImage(300, 300, { r: 34, g: 197, b: 94 });
|
||||
const file = new File([buffer], "test.jpg", { type: "image/jpeg" });
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
|
||||
const response = await fetch(`${BASE_URL}/api/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
return data.imageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the identify endpoint with a given imageId.
|
||||
*/
|
||||
async function callIdentify(imageId: string): Promise<{
|
||||
status: number;
|
||||
data: any;
|
||||
ok: boolean;
|
||||
}> {
|
||||
const response = await fetch(`${BASE_URL}/api/identify`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ imageId }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return { status: response.status, data, ok: response.ok };
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/identify", () => {
|
||||
let imageId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
imageId = await uploadTestImage();
|
||||
}, 15000);
|
||||
|
||||
it("returns 200 with predictions array for valid imageId", async () => {
|
||||
const { status, data } = await callIdentify(imageId);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data.predictions).toBeDefined();
|
||||
expect(Array.isArray(data.predictions)).toBe(true);
|
||||
expect(data.predictions.length).toBeGreaterThan(0);
|
||||
}, 30000);
|
||||
|
||||
it("returns 404 for invalid imageId", async () => {
|
||||
const { status, data } = await callIdentify("nonexistent-image-id");
|
||||
|
||||
expect(status).toBe(404);
|
||||
expect(data.error).toBe("Image not found");
|
||||
}, 15000);
|
||||
|
||||
it("returns 400 for missing imageId", async () => {
|
||||
const response = await fetch(`${BASE_URL}/api/identify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe("Missing imageId");
|
||||
}, 15000);
|
||||
|
||||
it("returns 400 for invalid JSON", async () => {
|
||||
const response = await fetch(`${BASE_URL}/api/identify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
}, 15000);
|
||||
|
||||
it("response includes metadata with model, inferenceTimeMs, imageId", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
expect(data.metadata).toBeDefined();
|
||||
expect(data.metadata.model).toBeDefined();
|
||||
expect(typeof data.metadata.model).toBe("string");
|
||||
expect(data.metadata.inferenceTimeMs).toBeDefined();
|
||||
expect(typeof data.metadata.inferenceTimeMs).toBe("number");
|
||||
expect(data.metadata.inferenceTimeMs).toBeGreaterThan(0);
|
||||
expect(data.metadata.imageId).toBe(imageId);
|
||||
}, 30000);
|
||||
|
||||
it("response includes demo_mode flag when using mock model", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
// In development without a real model, demo_mode should be true
|
||||
expect(typeof data.demo_mode).toBe("boolean");
|
||||
}, 30000);
|
||||
|
||||
it("each prediction has diseaseId, disease, confidence, and lookalikes", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
for (const pred of data.predictions) {
|
||||
expect(pred.diseaseId).toBeDefined();
|
||||
expect(typeof pred.diseaseId).toBe("string");
|
||||
expect(pred.disease).toBeDefined();
|
||||
expect(pred.disease.name).toBeDefined();
|
||||
expect(pred.disease.description).toBeDefined();
|
||||
expect(pred.disease.symptoms).toBeDefined();
|
||||
expect(pred.disease.treatment).toBeDefined();
|
||||
expect(pred.disease.prevention).toBeDefined();
|
||||
expect(pred.confidence).toBeDefined();
|
||||
expect(pred.confidence.raw).toBeDefined();
|
||||
expect(pred.confidence.adjusted).toBeDefined();
|
||||
expect(pred.confidence.label).toMatch(/^(high|medium|low)$/);
|
||||
expect(pred.lookalikes).toBeDefined();
|
||||
expect(Array.isArray(pred.lookalikes)).toBe(true);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("each prediction's diseaseId exists in knowledge base", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
for (const pred of data.predictions) {
|
||||
const disease = getDiseaseById(pred.diseaseId);
|
||||
expect(disease).toBeDefined();
|
||||
expect(disease!.id).toBe(pred.diseaseId);
|
||||
expect(disease!.name).toBe(pred.disease.name);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("predictions are sorted by confidence descending", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
for (let i = 0; i < data.predictions.length - 1; i++) {
|
||||
expect(data.predictions[i].confidence.adjusted).toBeGreaterThanOrEqual(
|
||||
data.predictions[i + 1].confidence.adjusted
|
||||
);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("lookalike references are valid disease IDs", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
for (const pred of data.predictions) {
|
||||
for (const lookalikeId of pred.lookalikes) {
|
||||
const lookalike = getDiseaseById(lookalikeId);
|
||||
expect(lookalike).toBeDefined();
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("inference completes under 3 seconds", async () => {
|
||||
const start = performance.now();
|
||||
const { data } = await callIdentify(imageId);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
expect(data.metadata.inferenceTimeMs).toBeLessThan(3000);
|
||||
expect(elapsed).toBeLessThan(3000);
|
||||
}, 10000);
|
||||
|
||||
it("confidence scores are in valid range", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
for (const pred of data.predictions) {
|
||||
expect(pred.confidence.raw).toBeGreaterThanOrEqual(0);
|
||||
expect(pred.confidence.raw).toBeLessThanOrEqual(1);
|
||||
expect(pred.confidence.adjusted).toBeGreaterThanOrEqual(0);
|
||||
expect(pred.confidence.adjusted).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("disease entries have required fields", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
for (const pred of data.predictions) {
|
||||
const disease = pred.disease;
|
||||
expect(disease.id).toBeDefined();
|
||||
expect(disease.plantId).toBeDefined();
|
||||
expect(disease.name).toBeDefined();
|
||||
expect(disease.scientificName).toBeDefined();
|
||||
expect(disease.causalAgentType).toMatch(/^(fungal|bacterial|viral|environmental)$/);
|
||||
expect(disease.description).toBeDefined();
|
||||
expect(disease.symptoms.length).toBeGreaterThanOrEqual(3);
|
||||
expect(disease.causes.length).toBeGreaterThanOrEqual(2);
|
||||
expect(disease.treatment.length).toBeGreaterThanOrEqual(3);
|
||||
expect(disease.prevention.length).toBeGreaterThanOrEqual(2);
|
||||
expect(disease.severity).toMatch(/^(low|moderate|high|critical)$/);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
263
apps/web/src/app/api/identify/route.ts
Normal file
263
apps/web/src/app/api/identify/route.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Plant disease identification endpoint.
|
||||
*
|
||||
* Accepts POST with { imageId } from a previous /api/upload call.
|
||||
* Loads the uploaded image, preprocesses it (resize + normalize),
|
||||
* runs ML inference, enriches results with the knowledge base,
|
||||
* and returns ranked predictions with confidence scores.
|
||||
*
|
||||
* When no model is available, returns deterministic mock predictions
|
||||
* with demo_mode: true so the UI still works for development.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
|
||||
import { runInference, INPUT_SIZE } from "@/lib/ml/inference";
|
||||
import { softmaxFloat32, getTopKFloat32, calibrateConfidence, filterByConfidence, DEFAULT_MIN_CONFIDENCE } from "@/lib/ml/confidence";
|
||||
import { getDiseaseIdForIndex } from "@/lib/ml/labels";
|
||||
import { getModel, MODEL_ID } from "@/lib/ml/model-loader";
|
||||
import { getDiseaseById, getLookalikeDiseases } from "@/lib/api/diseases";
|
||||
import type { IdentifyRequest, IdentifyResponse, PredictionResult, Disease } from "@/lib/types";
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const UPLOADS_DIR = path.join(process.cwd(), "public", "uploads");
|
||||
|
||||
/** ImageNet normalization constants */
|
||||
const IMAGENET_MEAN = [0.485, 0.456, 0.406] as const;
|
||||
const IMAGENET_STD = [0.229, 0.224, 0.225] as const;
|
||||
|
||||
/** Model input size */
|
||||
const MODEL_SIZE = 224;
|
||||
|
||||
// ─── Server-side image preprocessing ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load an uploaded image and preprocess it into a Float32Array tensor
|
||||
* with shape [3, 224, 224] (NCHW without batch dim) using ImageNet normalization.
|
||||
*
|
||||
* @param imageId - The image ID from the upload endpoint
|
||||
* @returns Float32Array tensor ready for inference
|
||||
* @throws Error if image not found
|
||||
*/
|
||||
async function loadImageAndPreprocess(imageId: string): Promise<Float32Array> {
|
||||
// Find the image file — try original first, then resized version
|
||||
const uploads = await fs.readdir(UPLOADS_DIR).catch(() => []);
|
||||
|
||||
// Find files matching this imageId
|
||||
const matchingFiles = uploads.filter(f => f.startsWith(imageId) && !f.includes("-resized"));
|
||||
if (matchingFiles.length === 0) {
|
||||
// Try the resized version
|
||||
const resizedFile = `${imageId}-resized.jpg`;
|
||||
if (fsSync.existsSync(path.join(UPLOADS_DIR, resizedFile))) {
|
||||
return preprocessImageBuffer(
|
||||
await fs.readFile(path.join(UPLOADS_DIR, resizedFile))
|
||||
);
|
||||
}
|
||||
throw new Error(`Image not found: ${imageId}`);
|
||||
}
|
||||
|
||||
const filename = matchingFiles[0];
|
||||
const filePath = path.join(UPLOADS_DIR, filename);
|
||||
|
||||
const buffer = await fs.readFile(filePath);
|
||||
return preprocessImageBuffer(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess an image buffer into a normalized Float32Array tensor.
|
||||
* Uses sharp to resize, then applies ImageNet normalization.
|
||||
*
|
||||
* Output: flat Float32Array of length 3 × 224 × 224 in channel-first order
|
||||
* (all R values, then all G values, then all B values).
|
||||
*
|
||||
* @param buffer - Raw image buffer
|
||||
* @returns Normalized Float32Array tensor
|
||||
*/
|
||||
async function preprocessImageBuffer(buffer: Buffer): Promise<Float32Array> {
|
||||
const sharpMod = await import("sharp");
|
||||
const sharp = sharpMod.default;
|
||||
|
||||
// Resize to model input size and get raw pixel data
|
||||
const pipeline = sharp(buffer)
|
||||
.resize(MODEL_SIZE, MODEL_SIZE)
|
||||
.raw()
|
||||
.ensureAlpha(0); // RGB only, no alpha
|
||||
|
||||
const rawBuffer = await pipeline.toBuffer();
|
||||
|
||||
// Convert to Float32Array with channel-first layout and ImageNet normalization
|
||||
const totalPixels = MODEL_SIZE * MODEL_SIZE;
|
||||
const tensor = new Float32Array(3 * totalPixels);
|
||||
|
||||
// Extract channels from raw RGB data
|
||||
const rChannel = tensor.subarray(0, totalPixels);
|
||||
const gChannel = tensor.subarray(totalPixels, 2 * totalPixels);
|
||||
const bChannel = tensor.subarray(2 * totalPixels, 3 * totalPixels);
|
||||
|
||||
for (let i = 0; i < totalPixels; i++) {
|
||||
const idx = i * 3; // RGB stride
|
||||
rChannel[i] = rawBuffer[idx] / 255;
|
||||
gChannel[i] = rawBuffer[idx + 1] / 255;
|
||||
bChannel[i] = rawBuffer[idx + 2] / 255;
|
||||
}
|
||||
|
||||
// Apply ImageNet normalization
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const channel = c === 0 ? rChannel : c === 1 ? gChannel : bChannel;
|
||||
const m = IMAGENET_MEAN[c];
|
||||
const s = IMAGENET_STD[c];
|
||||
for (let i = 0; i < totalPixels; i++) {
|
||||
channel[i] = (channel[i] - m) / s;
|
||||
}
|
||||
}
|
||||
|
||||
return tensor;
|
||||
}
|
||||
|
||||
// ─── Result enrichment ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Enrich raw predictions with knowledge base data.
|
||||
*
|
||||
* For each prediction:
|
||||
* - Look up disease by ID in knowledge base
|
||||
* - Calibrate confidence score
|
||||
* - Include lookalike disease cross-references
|
||||
*
|
||||
* @param topPredictions - Top-K raw predictions from inference
|
||||
* @returns Enriched prediction results
|
||||
*/
|
||||
function enrichPredictions(
|
||||
topPredictions: Array<{ classIndex: number; probability: number }>,
|
||||
): PredictionResult[] {
|
||||
const results: PredictionResult[] = [];
|
||||
|
||||
for (const pred of topPredictions) {
|
||||
const diseaseId = getDiseaseIdForIndex(pred.classIndex);
|
||||
|
||||
// Skip "healthy" and "unknown" — only return actual diseases
|
||||
if (diseaseId === "healthy" || diseaseId === "unknown") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look up disease in knowledge base
|
||||
const disease = getDiseaseById(diseaseId);
|
||||
if (!disease) {
|
||||
// Disease ID from model doesn't exist in knowledge base — skip
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calibrate confidence
|
||||
const confidence = calibrateConfidence(pred.probability);
|
||||
|
||||
// Get lookalike diseases
|
||||
const lookalikes = disease.lookalikeDiseaseIds;
|
||||
|
||||
results.push({
|
||||
diseaseId,
|
||||
disease,
|
||||
confidence,
|
||||
lookalikes,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by adjusted confidence descending
|
||||
results.sort((a, b) => b.confidence.adjusted - a.confidence.adjusted);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Route Handler ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Parse request body
|
||||
let body: IdentifyRequest;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON", message: "Request body must be valid JSON.", status: 400 },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { imageId } = body;
|
||||
|
||||
// Validate imageId
|
||||
if (!imageId || typeof imageId !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing imageId", message: 'Request body must include "imageId" string.', status: 400 },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check image exists
|
||||
const uploads = await fs.readdir(UPLOADS_DIR).catch(() => []);
|
||||
const imageExists = uploads.some(f => f.startsWith(imageId));
|
||||
if (!imageExists) {
|
||||
return NextResponse.json(
|
||||
{ error: "Image not found", message: `No image found with ID: ${imageId}`, status: 404 },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Load and preprocess image
|
||||
const tensor = await loadImageAndPreprocess(imageId);
|
||||
|
||||
// Run inference
|
||||
const { predictions: rawPredictions, inferenceTimeMs } = await runInference(tensor, 10);
|
||||
|
||||
// Get model status to check if we're in demo mode
|
||||
const model = await getModel();
|
||||
const modelStatus = model.getStatus();
|
||||
const demoMode = !modelStatus.loaded;
|
||||
|
||||
// Calibrate and filter predictions
|
||||
const calibratedPredictions = rawPredictions.map(pred => ({
|
||||
classIndex: pred.classIndex,
|
||||
probability: pred.probability,
|
||||
}));
|
||||
|
||||
// Enrich with knowledge base
|
||||
const enrichedPredictions = enrichPredictions(calibratedPredictions);
|
||||
|
||||
// Build response
|
||||
const response: IdentifyResponse = {
|
||||
predictions: enrichedPredictions,
|
||||
metadata: {
|
||||
model: modelStatus.modelId,
|
||||
inferenceTimeMs,
|
||||
imageId,
|
||||
},
|
||||
};
|
||||
|
||||
if (demoMode) {
|
||||
response.demo_mode = true;
|
||||
}
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
const status = message.includes("not found") ? 404 : 500;
|
||||
console.error(`[identify] Error: ${message}`);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: status === 404 ? "Image not found" : "Identification failed",
|
||||
message,
|
||||
status,
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
}
|
||||
37
apps/web/src/app/api/plants/[id]/route.ts
Normal file
37
apps/web/src/app/api/plants/[id]/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getPlantWithDiseases } from "@/lib/api/diseases";
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/plants/[id]
|
||||
* Get a single plant with all its associated diseases.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: RouteParams
|
||||
): Promise<NextResponse> {
|
||||
const { id } = await params;
|
||||
|
||||
console.log(`[API] GET /api/plants/${id}`);
|
||||
|
||||
const result = getPlantWithDiseases(id);
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Not Found",
|
||||
message: `Plant with ID "${id}" not found`,
|
||||
status: 404,
|
||||
},
|
||||
{ status: 404, headers: { "Cache-Control": "public, max-age=3600" } }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(result, {
|
||||
headers: { "Cache-Control": "public, max-age=3600" },
|
||||
});
|
||||
}
|
||||
65
apps/web/src/app/api/plants/route.ts
Normal file
65
apps/web/src/app/api/plants/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { listPlants } from "@/lib/api/diseases";
|
||||
|
||||
/**
|
||||
* GET /api/plants
|
||||
* List all plants or search by term.
|
||||
* Query params: ?search=<term> & ?category=<category>
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const search = searchParams.get("search");
|
||||
const category = searchParams.get("category") as
|
||||
| "vegetable"
|
||||
| "herb"
|
||||
| "houseplant"
|
||||
| "flower"
|
||||
| "fruit"
|
||||
| "succulent"
|
||||
| "tree"
|
||||
| null;
|
||||
|
||||
// Validate search param
|
||||
if (search !== null && search.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bad Request", message: "Search term cannot be empty", status: 400 },
|
||||
{ status: 400, headers: { "Cache-Control": "public, max-age=3600" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate category param
|
||||
const validCategories = [
|
||||
"vegetable",
|
||||
"herb",
|
||||
"houseplant",
|
||||
"flower",
|
||||
"fruit",
|
||||
"succulent",
|
||||
"tree",
|
||||
];
|
||||
if (category !== null && !validCategories.includes(category)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Bad Request",
|
||||
message: `Invalid category. Must be one of: ${validCategories.join(", ")}`,
|
||||
status: 400,
|
||||
},
|
||||
{ status: 400, headers: { "Cache-Control": "public, max-age=3600" } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[API] GET /api/plants search="${search}" category="${category}"`
|
||||
);
|
||||
|
||||
const results = listPlants({
|
||||
search: search || undefined,
|
||||
category: category || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ plants: results, total: results.length },
|
||||
{ headers: { "Cache-Control": "public, max-age=3600" } }
|
||||
);
|
||||
}
|
||||
188
apps/web/src/app/api/upload/route.ts
Normal file
188
apps/web/src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Server-side image upload endpoint.
|
||||
*
|
||||
* Accepts multipart/form-data with field "image".
|
||||
* Validates MIME type, file size, and minimum dimensions.
|
||||
* Saves to public/uploads/{uuid}.{ext}.
|
||||
* Runs server-side preprocessing (resize + normalize).
|
||||
* Returns { imageId, tensorShape, previewUrl }.
|
||||
*
|
||||
* Cleanup: keeps last MAX_UPLOADS files, purges older ones.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
ALLOWED_MIME_TYPES,
|
||||
MAX_FILE_SIZE,
|
||||
MIN_DIMENSION,
|
||||
MAX_UPLOADS,
|
||||
getTensorShape,
|
||||
} from "@/lib/image-processing";
|
||||
import {
|
||||
mimeTypeToExtension,
|
||||
resizeImageServer,
|
||||
} from "@/lib/server/image-processing-server";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
|
||||
// Uploads directory
|
||||
const UPLOADS_DIR = path.join(process.cwd(), "public", "uploads");
|
||||
|
||||
// Ensure uploads directory exists
|
||||
async function ensureUploadsDir(): Promise<void> {
|
||||
await fs.mkdir(UPLOADS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* List existing uploads sorted by modification time (oldest first).
|
||||
*/
|
||||
async function listUploads(): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(UPLOADS_DIR);
|
||||
const files = await Promise.all(
|
||||
entries.map(async (name) => {
|
||||
const stat = await fs.stat(path.join(UPLOADS_DIR, name));
|
||||
return { name, mtime: stat.mtimeMs };
|
||||
}),
|
||||
);
|
||||
return files.sort((a, b) => a.mtime - b.mtime).map((f) => f.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge oldest uploads to stay within MAX_UPLOADS limit.
|
||||
*/
|
||||
async function cleanupOldUploads(): Promise<void> {
|
||||
const files = await listUploads();
|
||||
const toDelete = files.slice(0, files.length - MAX_UPLOADS);
|
||||
await Promise.all(
|
||||
toDelete.map((name) =>
|
||||
fs.unlink(path.join(UPLOADS_DIR, name)).catch(() => {}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read image dimensions from a buffer without full decode.
|
||||
* Uses sharp to get metadata efficiently.
|
||||
*/
|
||||
async function getImageDimensions(buffer: Buffer): Promise<{
|
||||
width: number;
|
||||
height: number;
|
||||
}> {
|
||||
try {
|
||||
const sharpMod = await import("sharp");
|
||||
const sharp = sharpMod.default;
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
return {
|
||||
width: metadata.width ?? 0,
|
||||
height: metadata.height ?? 0,
|
||||
};
|
||||
} catch {
|
||||
throw new Error("sharp is required for image dimension validation.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Route Handler ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await ensureUploadsDir();
|
||||
|
||||
// Parse multipart form data
|
||||
const formData = await request.formData();
|
||||
const imageFile = formData.get("image");
|
||||
|
||||
if (!imageFile || !(imageFile instanceof File)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing file", message: 'No "image" field in form data.', status: 400 },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate MIME type
|
||||
const mimeType = imageFile.type;
|
||||
if (!ALLOWED_MIME_TYPES.includes(mimeType as (typeof ALLOWED_MIME_TYPES)[number])) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid MIME type",
|
||||
message: `Type "${mimeType}" not allowed. Allowed: ${ALLOWED_MIME_TYPES.join(", ")}.`,
|
||||
status: 400,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (imageFile.size > MAX_FILE_SIZE) {
|
||||
const mb = (imageFile.size / (1024 * 1024)).toFixed(1);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "File too large",
|
||||
message: `File is ${mb} MB. Maximum is 10 MB.`,
|
||||
status: 413,
|
||||
},
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
// Read file buffer
|
||||
const buffer = Buffer.from(await imageFile.arrayBuffer());
|
||||
|
||||
// Validate dimensions
|
||||
const { width, height } = await getImageDimensions(buffer);
|
||||
if (width < MIN_DIMENSION || height < MIN_DIMENSION) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Image too small",
|
||||
message: `Image is ${width}×${height}. Minimum is ${MIN_DIMENSION}×${MIN_DIMENSION}.`,
|
||||
status: 400,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const imageId = uuidv4();
|
||||
const ext = mimeTypeToExtension(mimeType);
|
||||
const filename = `${imageId}.${ext}`;
|
||||
const filePath = path.join(UPLOADS_DIR, filename);
|
||||
|
||||
// Save original file
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
// Server-side preprocessing: resize to model size
|
||||
const modelSize = 224;
|
||||
const resizedBuffer = await resizeImageServer(buffer, modelSize);
|
||||
|
||||
// Save resized version (for preview / model input)
|
||||
const resizedFilename = `${imageId}-resized.jpg`;
|
||||
const resizedPath = path.join(UPLOADS_DIR, resizedFilename);
|
||||
await fs.writeFile(resizedPath, resizedBuffer);
|
||||
|
||||
// Cleanup old uploads
|
||||
await cleanupOldUploads();
|
||||
|
||||
// Return response
|
||||
return NextResponse.json({
|
||||
imageId,
|
||||
tensorShape: getTensorShape(),
|
||||
previewUrl: `/uploads/${filename}`,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("[upload] Error:", message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Upload failed",
|
||||
message,
|
||||
status: 500,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
141
apps/web/src/app/api/upload/upload.test.ts
Normal file
141
apps/web/src/app/api/upload/upload.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Integration tests for app/api/upload/route.ts
|
||||
*
|
||||
* These tests call the actual running dev server via fetch.
|
||||
* Run with: `npm run dev` in one terminal, then `npx vitest run src/app/api/upload/upload.test.ts`
|
||||
*
|
||||
* Tests:
|
||||
* - Upload a valid JPG → POST /api/upload returns 200 with expected shape
|
||||
* - Upload a 12 MB file → returns 413 or validation error
|
||||
* - Upload a .txt file → returns 400 with MIME error
|
||||
* - Upload missing field → returns 400
|
||||
* - Upload too-small image → returns 400
|
||||
*/
|
||||
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
|
||||
const BASE_URL = process.env.TEST_BASE_URL || "http://localhost:3000";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a test image buffer using sharp.
|
||||
*/
|
||||
async function createTestImage(
|
||||
width: number,
|
||||
height: number,
|
||||
bg = { r: 34, g: 197, b: 94 },
|
||||
): Promise<Buffer> {
|
||||
const sharpMod = await import("sharp");
|
||||
const sharp = sharpMod.default;
|
||||
return sharp({
|
||||
create: { width, height, channels: 3, background: bg },
|
||||
})
|
||||
.jpeg({ quality: 90 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to the /api/upload endpoint and return the response.
|
||||
*/
|
||||
async function uploadFile(file: File): Promise<{
|
||||
status: number;
|
||||
data: any;
|
||||
ok: boolean;
|
||||
}> {
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
|
||||
const response = await fetch(`${BASE_URL}/api/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return { status: response.status, data, ok: response.ok };
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/upload", () => {
|
||||
beforeAll(() => {
|
||||
// Ensure the dev server is running
|
||||
if (!BASE_URL) {
|
||||
console.warn(
|
||||
"TEST_BASE_URL not set. Using default http://localhost:3000",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a valid JPEG and returns imageId, tensorShape, previewUrl", async () => {
|
||||
const buffer = await createTestImage(300, 300);
|
||||
const file = new File([buffer], "test.jpg", { type: "image/jpeg" });
|
||||
const { status, data } = await uploadFile(file);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data.imageId).toBeDefined();
|
||||
expect(typeof data.imageId).toBe("string");
|
||||
expect(data.imageId.length).toBeGreaterThan(0);
|
||||
expect(data.tensorShape).toEqual([1, 3, 224, 224]);
|
||||
expect(data.previewUrl).toMatch(/^\/uploads\/.*\.jpg$/);
|
||||
}, 15000);
|
||||
|
||||
it("accepts a valid PNG", async () => {
|
||||
const buffer = await createTestImage(200, 200);
|
||||
const file = new File([buffer], "test.png", { type: "image/png" });
|
||||
const { status, data } = await uploadFile(file);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data.previewUrl).toMatch(/^\/uploads\/.*\.png$/);
|
||||
}, 15000);
|
||||
|
||||
it("accepts a valid WebP", async () => {
|
||||
const buffer = await createTestImage(200, 200);
|
||||
const file = new File([buffer], "test.webp", { type: "image/webp" });
|
||||
const { status } = await uploadFile(file);
|
||||
|
||||
expect(status).toBe(200);
|
||||
}, 15000);
|
||||
|
||||
it("rejects a .txt file with 400 MIME error", async () => {
|
||||
const file = new File(["not an image"], "document.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
const { status, data } = await uploadFile(file);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(data.error).toBe("Invalid MIME type");
|
||||
expect(data.message).toContain("text/plain");
|
||||
}, 15000);
|
||||
|
||||
it("rejects a file >10 MB with 413", async () => {
|
||||
const bigBuffer = Buffer.alloc(12 * 1024 * 1024);
|
||||
const file = new File([bigBuffer], "huge.jpg", { type: "image/jpeg" });
|
||||
const { status, data } = await uploadFile(file);
|
||||
|
||||
expect(status).toBe(413);
|
||||
expect(data.error).toBe("File too large");
|
||||
}, 30000);
|
||||
|
||||
it("rejects an image that is too small (<150×150)", async () => {
|
||||
const buffer = await createTestImage(50, 50);
|
||||
const file = new File([buffer], "tiny.jpg", { type: "image/jpeg" });
|
||||
const { status, data } = await uploadFile(file);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(data.error).toBe("Image too small");
|
||||
expect(data.message).toContain("150");
|
||||
}, 15000);
|
||||
|
||||
it("returns tensorShape [1, 3, 224, 224] for any valid input", async () => {
|
||||
// Test with a non-square image
|
||||
const buffer = await createTestImage(400, 200);
|
||||
const file = new File([buffer], "wide.jpg", { type: "image/jpeg" });
|
||||
const { status, data } = await uploadFile(file);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data.tensorShape).toEqual([1, 3, 224, 224]);
|
||||
}, 15000);
|
||||
});
|
||||
169
apps/web/src/app/browse/BrowseContent.tsx
Normal file
169
apps/web/src/app/browse/BrowseContent.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import PlantCard from "@/components/PlantCard";
|
||||
import EmptyState from "@/components/EmptyState";
|
||||
import { plants, type Plant } from "@/data/plants";
|
||||
import { PLANT_CATEGORIES } from "@/lib/constants";
|
||||
|
||||
type Category = Plant["category"] | "all";
|
||||
|
||||
/**
|
||||
* Client component that handles the interactive browse/search/filter logic.
|
||||
* Wrapped in a Suspense boundary in the parent page.
|
||||
*/
|
||||
export default function BrowseContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialSearch = searchParams.get("search") || "";
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
||||
const [activeCategory, setActiveCategory] = useState<Category>("all");
|
||||
|
||||
const filteredPlants = useMemo(() => {
|
||||
let result = plants;
|
||||
|
||||
if (activeCategory !== "all") {
|
||||
result = result.filter((p) => p.category === activeCategory);
|
||||
}
|
||||
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
if (q) {
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.commonName.toLowerCase().includes(q) ||
|
||||
p.scientificName.toLowerCase().includes(q) ||
|
||||
p.family.toLowerCase().includes(q) ||
|
||||
p.diseases.some((d) => d.name.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [activeCategory, searchQuery]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||
{/* Page header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-zinc-900 dark:text-zinc-100">
|
||||
Browse Plants
|
||||
</h1>
|
||||
<p className="mt-2 text-zinc-500 dark:text-zinc-400">
|
||||
Explore our database of {plants.length} plants and their common
|
||||
diseases.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="relative mb-6">
|
||||
<label htmlFor="browse-search" className="sr-only">
|
||||
Search plants and diseases
|
||||
</label>
|
||||
<div className="relative">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
id="browse-search"
|
||||
type="search"
|
||||
placeholder="Search by plant name, scientific name, or disease..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 pl-10 pr-4 py-3 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category filter chips */}
|
||||
<div
|
||||
className="flex flex-wrap gap-2 mb-8"
|
||||
role="tablist"
|
||||
aria-label="Plant categories"
|
||||
>
|
||||
{PLANT_CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.value}
|
||||
role="tab"
|
||||
aria-selected={activeCategory === cat.value}
|
||||
onClick={() => setActiveCategory(cat.value as Category)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-full transition-all focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2 ${
|
||||
activeCategory === cat.value
|
||||
? "bg-leaf-green-600 text-white shadow-sm"
|
||||
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
|
||||
{filteredPlants.length === 0
|
||||
? "No plants found"
|
||||
: `Showing ${filteredPlants.length} ${filteredPlants.length === 1 ? "plant" : "plants"}`}
|
||||
{activeCategory !== "all" &&
|
||||
` in ${PLANT_CATEGORIES.find((c) => c.value === activeCategory)?.label.toLowerCase()}`}
|
||||
{searchQuery.trim() && ` matching "${searchQuery.trim()}"`}
|
||||
</p>
|
||||
|
||||
{/* Plant grid or empty state */}
|
||||
{filteredPlants.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{filteredPlants.map((plant) => (
|
||||
<PlantCard key={plant.id} plant={plant} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
illustration="🔍"
|
||||
title="No plants found"
|
||||
description={
|
||||
searchQuery.trim()
|
||||
? `We couldn't find any plants matching "${searchQuery.trim()}". Try a different search term or browse a different category.`
|
||||
: "No plants in this category yet. We're constantly adding new plants to our database."
|
||||
}
|
||||
actionLabel="Clear filters"
|
||||
actionHref="/browse"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
297
apps/web/src/app/browse/[plantId]/page.tsx
Normal file
297
apps/web/src/app/browse/[plantId]/page.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getPlantById, type Disease } from "@/data/plants";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ plantId: string }>;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const { plants } = await import("@/data/plants");
|
||||
return plants.map((plant) => ({
|
||||
plantId: plant.id,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { plantId } = await params;
|
||||
const plant = getPlantById(plantId);
|
||||
|
||||
if (!plant) {
|
||||
return { title: "Plant Not Found" };
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${plant.commonName} — Diseases & Care`,
|
||||
description: `Learn about ${plant.commonName} (${plant.scientificName}) diseases, symptoms, causes, and treatments. ${plant.diseases.length} diseases documented.`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ─── Severity badge ─── */
|
||||
function SeverityBadge({ severity }: { severity: Disease["severity"] }) {
|
||||
const colors: Record<Disease["severity"], string> = {
|
||||
low: "bg-leaf-green-100 text-leaf-green-800 dark:bg-leaf-green-900/40 dark:text-leaf-green-300",
|
||||
moderate: "bg-warning-amber-100 text-warning-amber-800 dark:bg-warning-amber-900/40 dark:text-warning-amber-300",
|
||||
high: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
|
||||
critical: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
|
||||
};
|
||||
|
||||
const labels: Record<Disease["severity"], string> = {
|
||||
low: "Low",
|
||||
moderate: "Moderate",
|
||||
high: "High",
|
||||
critical: "Critical",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[severity]}`}
|
||||
>
|
||||
{severity === "critical" ? "🚨 " : ""}
|
||||
{labels[severity]} Severity
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Disease type badge ─── */
|
||||
function TypeBadge({ type }: { type: Disease["type"] }) {
|
||||
const colors: Record<Disease["type"], string> = {
|
||||
fungal: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
|
||||
bacterial: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
viral: "bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300",
|
||||
pest: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
|
||||
physiological: "bg-zinc-100 text-zinc-800 dark:bg-zinc-700 dark:text-zinc-300",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[type]}`}
|
||||
>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Disease card (expandable) ─── */
|
||||
function DiseaseCard({ disease }: { disease: Disease }) {
|
||||
return (
|
||||
<div
|
||||
id={`disease-${disease.id}`}
|
||||
className="rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
{/* Card header */}
|
||||
<div className="p-5 sm:p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{disease.name}
|
||||
</h3>
|
||||
{disease.scientificName && (
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 italic mt-0.5">
|
||||
{disease.scientificName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TypeBadge type={disease.type} />
|
||||
<SeverityBadge severity={disease.severity} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed mb-4">
|
||||
{disease.description}
|
||||
</p>
|
||||
|
||||
{/* Details grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Symptoms */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">⚠️</span> Symptoms
|
||||
</h4>
|
||||
<ul className="space-y-1.5">
|
||||
{disease.symptoms.map((symptom, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
|
||||
>
|
||||
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-red-400 dark:bg-red-500" />
|
||||
{symptom}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Causes */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-600 dark:text-orange-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">🔍</span> Causes
|
||||
</h4>
|
||||
<ul className="space-y-1.5">
|
||||
{disease.causes.map((cause, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
|
||||
>
|
||||
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-orange-400 dark:bg-orange-500" />
|
||||
{cause}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Treatment Steps */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">💊</span> Treatment Steps
|
||||
</h4>
|
||||
<ol className="space-y-1.5 list-decimal list-inside">
|
||||
{disease.treatmentSteps.map((step, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="text-sm text-zinc-600 dark:text-zinc-300"
|
||||
>
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Prevention Tips */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">🛡️</span> Prevention Tips
|
||||
</h4>
|
||||
<ul className="space-y-1.5">
|
||||
{disease.preventionTips.map((tip, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
|
||||
>
|
||||
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-leaf-green-400 dark:bg-leaf-green-500" />
|
||||
{tip}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Plant Detail Page ─── */
|
||||
export default async function PlantDetailPage({ params }: Props) {
|
||||
const { plantId } = await params;
|
||||
const plant = getPlantById(plantId);
|
||||
|
||||
if (!plant) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-6 text-sm" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
|
||||
<li>
|
||||
<Link href="/" className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li>
|
||||
<Link href="/browse" className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors">
|
||||
Browse
|
||||
</Link>
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li className="text-zinc-800 dark:text-zinc-200 font-medium">
|
||||
{plant.commonName}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* Plant hero */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-6 mb-10">
|
||||
{/* Emoji illustration */}
|
||||
<div className="flex items-center justify-center h-32 w-32 sm:h-40 sm:w-40 shrink-0 rounded-2xl bg-gradient-to-br from-leaf-green-50 to-leaf-green-100 dark:from-leaf-green-950 dark:to-leaf-green-900">
|
||||
<span className="text-6xl sm:text-7xl" role="img" aria-hidden="true">
|
||||
{plant.imageEmoji}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-zinc-900 dark:text-zinc-100">
|
||||
{plant.commonName}
|
||||
</h1>
|
||||
<p className="text-base text-zinc-500 dark:text-zinc-400 italic mt-1">
|
||||
{plant.scientificName}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Family: <span className="font-medium">{plant.family}</span>
|
||||
{" · "}
|
||||
Category:{" "}
|
||||
<span className="font-medium capitalize">{plant.category}</span>
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">
|
||||
{plant.description}
|
||||
</p>
|
||||
<div className="mt-3 flex items-start gap-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<span aria-hidden="true">💚</span>
|
||||
<span>{plant.careSummary}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identify disease CTA */}
|
||||
<div className="mb-10 rounded-xl bg-gradient-to-r from-leaf-green-50 to-soil-brown-50 dark:from-leaf-green-950 dark:to-soil-brown-950 border border-leaf-green-200 dark:border-leaf-green-800 p-5 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
🧐 Spot a problem on your {plant.commonName.toLowerCase()}?
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-1">
|
||||
Upload a photo for AI-powered disease identification.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/browse"
|
||||
className="inline-flex items-center gap-2 shrink-0 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
📸 Identify a Disease
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disease list */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-2">
|
||||
Known Diseases
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
|
||||
{plant.diseases.length === 0
|
||||
? "No diseases currently documented for this plant."
|
||||
: `${plant.diseases.length} ${plant.diseases.length === 1 ? "disease" : "diseases"} documented for ${plant.commonName}.`}
|
||||
</p>
|
||||
|
||||
{plant.diseases.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{plant.diseases.map((disease) => (
|
||||
<DiseaseCard key={disease.id} disease={disease} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 p-10 text-center">
|
||||
<span className="text-4xl block mb-3" aria-hidden="true">🌿</span>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 text-sm">
|
||||
Disease data for {plant.commonName} is being researched and will be added soon.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
apps/web/src/app/browse/page.tsx
Normal file
34
apps/web/src/app/browse/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { Suspense } from "react";
|
||||
import BrowseContent from "./BrowseContent";
|
||||
import { PlantCardSkeleton } from "@/components/LoadingSkeleton";
|
||||
|
||||
/**
|
||||
* Browse page requires a Suspense boundary because it uses useSearchParams().
|
||||
* The actual interactive content is in BrowseContent (client component).
|
||||
*/
|
||||
export default function BrowsePage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||
<div className="mb-8">
|
||||
<div className="h-9 w-48 animate-pulse rounded bg-zinc-200 dark:bg-zinc-700" />
|
||||
<div className="mt-2 h-5 w-72 animate-pulse rounded bg-zinc-200 dark:bg-zinc-700" />
|
||||
</div>
|
||||
<div className="mb-6 h-12 w-full animate-pulse rounded-xl bg-zinc-200 dark:bg-zinc-700" />
|
||||
<div className="flex gap-2 mb-8">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-9 w-24 animate-pulse rounded-full bg-zinc-200 dark:bg-zinc-700"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<PlantCardSkeleton count={8} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BrowseContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
BIN
apps/web/src/app/favicon.ico
Normal file
BIN
apps/web/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
120
apps/web/src/app/globals.css
Normal file
120
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,120 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-inter);
|
||||
--font-mono: var(--font-inter);
|
||||
|
||||
/* Custom design tokens — plant disease identification theme */
|
||||
--color-leaf-green-50: #f0fdf4;
|
||||
--color-leaf-green-100: #dcfce7;
|
||||
--color-leaf-green-200: #bbf7d0;
|
||||
--color-leaf-green-300: #86efac;
|
||||
--color-leaf-green-400: #4ade80;
|
||||
--color-leaf-green-500: #22c55e;
|
||||
--color-leaf-green-600: #16a34a;
|
||||
--color-leaf-green-700: #15803d;
|
||||
--color-leaf-green-800: #166534;
|
||||
--color-leaf-green-900: #14532d;
|
||||
|
||||
--color-soil-brown-50: #fdf8f6;
|
||||
--color-soil-brown-100: #f2e8e5;
|
||||
--color-soil-brown-200: #e6d5ce;
|
||||
--color-soil-brown-300: #d4b5a9;
|
||||
--color-soil-brown-400: #c0907e;
|
||||
--color-soil-brown-500: #a3705a;
|
||||
--color-soil-brown-600: #8a5a48;
|
||||
--color-soil-brown-700: #724a3c;
|
||||
--color-soil-brown-800: #5e3e34;
|
||||
--color-soil-brown-900: #4d342b;
|
||||
|
||||
--color-warning-amber-50: #fffbeb;
|
||||
--color-warning-amber-100: #fef3c7;
|
||||
--color-warning-amber-200: #fde68a;
|
||||
--color-warning-amber-300: #fcd34d;
|
||||
--color-warning-amber-400: #fbbf24;
|
||||
--color-warning-amber-500: #f59e0b;
|
||||
--color-warning-amber-600: #d97706;
|
||||
--color-warning-amber-700: #b45309;
|
||||
--color-warning-amber-800: #92400e;
|
||||
--color-warning-amber-900: #78350f;
|
||||
|
||||
/* Extended spacing scale */
|
||||
--spacing-72: 18rem;
|
||||
--spacing-80: 20rem;
|
||||
--spacing-96: 24rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-inter), Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Smooth scroll behavior */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Reduce motion for users who prefer it */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus-visible outline for keyboard navigation */
|
||||
:focus-visible {
|
||||
outline: 2px solid #16a34a;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling (nice on supported browsers) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #d4d4d4;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #a3a3a3;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #525252;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #737373;
|
||||
}
|
||||
}
|
||||
52
apps/web/src/app/layout.tsx
Normal file
52
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import Footer from "@/components/Footer";
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
import { APP_NAME, APP_DESCRIPTION, BETA_DISCLAIMER } from "@/lib/constants";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: `${APP_NAME} — ${APP_DESCRIPTION}`,
|
||||
template: `%s | ${APP_NAME}`,
|
||||
},
|
||||
description: APP_DESCRIPTION,
|
||||
openGraph: {
|
||||
title: APP_NAME,
|
||||
description: APP_DESCRIPTION,
|
||||
type: "website",
|
||||
siteName: APP_NAME,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${inter.variable} h-full scroll-smooth antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 font-sans">
|
||||
<ErrorBoundary>
|
||||
<Navbar />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</ErrorBoundary>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
38
apps/web/src/app/not-found.tsx
Normal file
38
apps/web/src/app/not-found.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Page Not Found",
|
||||
};
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] px-6 py-16 text-center">
|
||||
<span className="text-7xl mb-6 block" aria-hidden="true">
|
||||
🍃
|
||||
</span>
|
||||
<h1 className="text-3xl font-bold text-zinc-900 dark:text-zinc-100 mb-3">
|
||||
Page Not Found
|
||||
</h1>
|
||||
<p className="text-zinc-600 dark:text-zinc-400 max-w-md leading-relaxed mb-8">
|
||||
This page doesn't seem to exist. Perhaps it wilted away, or the
|
||||
URL got pruned. Let's get you back to healthy ground.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
🏠 Go home
|
||||
</Link>
|
||||
<Link
|
||||
href="/browse"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-zinc-300 dark:border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
🌿 Browse plants
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
apps/web/src/app/page.tsx
Normal file
182
apps/web/src/app/page.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import PlantCard from "@/components/PlantCard";
|
||||
import { getFeaturedPlants } from "@/data/plants";
|
||||
import { TRUST_SIGNALS, HOW_IT_WORKS, APP_NAME, APP_TAGLINE } from "@/lib/constants";
|
||||
|
||||
export default function HomePage() {
|
||||
const featuredPlants = getFeaturedPlants();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* ─── Hero Section ─── */}
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-leaf-green-50 via-white to-leaf-green-50 dark:from-zinc-950 dark:via-zinc-950 dark:to-leaf-green-950">
|
||||
{/* Decorative background elements */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
|
||||
<div className="absolute -top-24 -right-24 h-96 w-96 rounded-full bg-leaf-green-100/40 dark:bg-leaf-green-900/20 blur-3xl" />
|
||||
<div className="absolute -bottom-24 -left-24 h-80 w-80 rounded-full bg-soil-brown-100/30 dark:bg-soil-brown-900/20 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-16 sm:py-24 lg:py-32">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* Plant emoji hero */}
|
||||
<span className="text-7xl sm:text-8xl mb-6" aria-hidden="true">
|
||||
🌱
|
||||
</span>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold tracking-tight text-zinc-900 dark:text-zinc-50 max-w-3xl">
|
||||
{APP_TAGLINE}
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 text-lg sm:text-xl text-zinc-600 dark:text-zinc-400 max-w-xl">
|
||||
Upload a photo of your plant and get a hyper-specific disease
|
||||
diagnosis with treatment steps, prevention tips, and confidence
|
||||
scores — all within seconds.
|
||||
</p>
|
||||
|
||||
{/* Upload CTA area */}
|
||||
<div className="mt-10 w-full max-w-lg">
|
||||
<Link
|
||||
href="/upload"
|
||||
className="inline-flex items-center gap-3 rounded-2xl border-2 border-dashed border-leaf-green-300 dark:border-leaf-green-700 bg-white/80 dark:bg-zinc-900/80 px-8 py-6 text-left shadow-sm hover:shadow-md hover:border-leaf-green-500 dark:hover:border-leaf-green-500 transition-all duration-200 group w-full"
|
||||
>
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-xl bg-leaf-green-100 dark:bg-leaf-green-900/50 text-2xl group-hover:scale-110 transition-transform">
|
||||
📸
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<span className="block text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Identify a Plant Disease
|
||||
</span>
|
||||
<span className="block text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||
Tap to upload a photo and get started
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-leaf-green-600 dark:text-leaf-green-400 text-xl group-hover:translate-x-1 transition-transform" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Trust signals */}
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-6 sm:gap-10">
|
||||
{TRUST_SIGNALS.map((signal) => (
|
||||
<div
|
||||
key={signal.label}
|
||||
className="flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
<span aria-hidden="true">{signal.icon}</span>
|
||||
<span>{signal.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── How It Works ─── */}
|
||||
<section className="py-16 sm:py-20 bg-white dark:bg-zinc-950">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-center text-zinc-900 dark:text-zinc-100">
|
||||
How It Works
|
||||
</h2>
|
||||
<p className="mt-3 text-center text-zinc-500 dark:text-zinc-400 max-w-lg mx-auto">
|
||||
Three simple steps to diagnose your plant in seconds.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 grid grid-cols-1 gap-8 sm:grid-cols-3">
|
||||
{HOW_IT_WORKS.map((step, index) => (
|
||||
<div
|
||||
key={step.step}
|
||||
className="relative flex flex-col items-center text-center"
|
||||
>
|
||||
{/* Connector line (desktop) */}
|
||||
{index < HOW_IT_WORKS.length - 1 && (
|
||||
<div
|
||||
className="hidden sm:block absolute top-12 left-[60%] w-[80%] h-px border-t-2 border-dashed border-leaf-green-200 dark:border-leaf-green-800"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step number badge */}
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-leaf-green-600 text-white text-sm font-bold mb-4">
|
||||
{step.step}
|
||||
</div>
|
||||
|
||||
<span className="text-5xl mb-4" aria-hidden="true">
|
||||
{step.emoji}
|
||||
</span>
|
||||
|
||||
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{step.title}
|
||||
</h3>
|
||||
|
||||
<p className="mt-2 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400 max-w-xs">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── Featured Plants ─── */}
|
||||
<section className="py-16 sm:py-20 bg-zinc-50 dark:bg-zinc-900">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-10">
|
||||
<div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-zinc-100">
|
||||
Featured Plants
|
||||
</h2>
|
||||
<p className="mt-2 text-zinc-500 dark:text-zinc-400">
|
||||
Browse our database of common garden plants and their diseases.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/browse"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-leaf-green-600 hover:text-leaf-green-700 dark:text-leaf-green-400 dark:hover:text-leaf-green-300 transition-colors"
|
||||
>
|
||||
View all plants
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{featuredPlants.map((plant) => (
|
||||
<PlantCard key={plant.id} plant={plant} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── Open Source CTA ─── */}
|
||||
<section className="py-16 sm:py-20 bg-white dark:bg-zinc-950">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 text-center">
|
||||
<span className="text-5xl mb-4 block" aria-hidden="true">
|
||||
🔓
|
||||
</span>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-zinc-100">
|
||||
Open Source & Community Driven
|
||||
</h2>
|
||||
<p className="mt-3 text-zinc-500 dark:text-zinc-400 max-w-lg mx-auto">
|
||||
{APP_NAME} is free and open source. Contributions, feedback, and
|
||||
plant data are welcome from gardeners and developers alike.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/about"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
<Link
|
||||
href="/browse"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-zinc-300 dark:border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
Browse Plants
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
apps/web/src/app/results/[imageId]/page.tsx
Normal file
114
apps/web/src/app/results/[imageId]/page.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, Suspense } from "react";
|
||||
import { use } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { IdentifyResponse } from "@/lib/types";
|
||||
import { identifyPlant, IdentifyError } from "@/lib/api/identify";
|
||||
import ResultsDashboard from "@/components/ResultsDashboard";
|
||||
import EmptyState from "@/components/EmptyState";
|
||||
|
||||
/**
|
||||
* Results page route that takes imageId from URL param.
|
||||
*
|
||||
* Fetches identification results via client-side API call (to avoid serverless timeouts).
|
||||
* Layout: side-by-side on desktop (image left, results right), stacked on mobile.
|
||||
* Loading skeleton state while results are computed.
|
||||
* Error state if identification fails.
|
||||
* Empty/unexpected state.
|
||||
*/
|
||||
export default function ResultsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ imageId: string }>;
|
||||
}) {
|
||||
const { imageId } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
const [response, setResponse] = useState<IdentifyResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const runIdentification = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResponse(null);
|
||||
|
||||
try {
|
||||
const result = await identifyPlant(imageId);
|
||||
setResponse(result);
|
||||
} catch (err) {
|
||||
const identifyErr = err as IdentifyError;
|
||||
if (identifyErr.status === 404) {
|
||||
setError("Image not found. It may have been deleted or expired.");
|
||||
} else {
|
||||
setError(identifyErr.message || "Identification failed. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [imageId]);
|
||||
|
||||
// Run identification on mount
|
||||
useEffect(() => {
|
||||
runIdentification();
|
||||
}, [runIdentification]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
runIdentification();
|
||||
}, [runIdentification]);
|
||||
|
||||
const handleTryAgain = useCallback(() => {
|
||||
router.push("/");
|
||||
}, [router]);
|
||||
|
||||
// ─── Error state with retry ───────────────────────────────────────────────
|
||||
|
||||
if (error && !loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] px-6 py-16 text-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="text-6xl mb-6" aria-hidden="true">🍂</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-zinc-900 dark:text-zinc-100 mb-3">
|
||||
Identification Failed
|
||||
</h2>
|
||||
|
||||
<p className="text-zinc-600 dark:text-zinc-400 mb-6 leading-relaxed">
|
||||
{error}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.824 0c-.158.39-.472.738-.893.893a.75.75 0 11-.53-1.403A4.001 4.001 0 006.5 9a4.001 4.001 0 00-1.924 1.913.75.75 0 11-1.404-.53 5.5 5.5 0 019.824 0c.158-.39.472-.738.893-.893a.75.75 0 11.53 1.403z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={handleTryAgain}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-zinc-300 dark:border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
Upload another photo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main dashboard (loading + results) ───────────────────────────────────
|
||||
|
||||
return (
|
||||
<ResultsDashboard
|
||||
imageId={imageId}
|
||||
imageUrl={`/uploads/${imageId}`}
|
||||
response={response}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
9
apps/web/src/app/results/page.tsx
Normal file
9
apps/web/src/app/results/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Redirect page — if someone navigates to /results without an imageId,
|
||||
* redirect them to the homepage.
|
||||
*/
|
||||
export default function ResultsRedirectPage() {
|
||||
redirect("/");
|
||||
}
|
||||
74
apps/web/src/app/upload/page.tsx
Normal file
74
apps/web/src/app/upload/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ImageUpload from "@/components/ImageUpload";
|
||||
import type { UploadResponse } from "@/lib/api/upload";
|
||||
|
||||
/**
|
||||
* Upload page — user uploads a plant image and gets redirected to results.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User uploads image via ImageUpload component
|
||||
* 2. On success, navigate to /results/{imageId}
|
||||
* 3. Results page runs identification via client-side fetch
|
||||
*/
|
||||
export default function UploadPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const handleUpload = useCallback(
|
||||
(response: UploadResponse) => {
|
||||
// Navigate to results page with the imageId
|
||||
router.push(`/results/${response.imageId}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleError = useCallback((error: string) => {
|
||||
console.error("[upload] Upload failed:", error);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 sm:px-6 lg:px-8 py-12 sm:py-20">
|
||||
{/* Page header */}
|
||||
<div className="text-center mb-10">
|
||||
<span className="text-5xl block mb-4" aria-hidden="true">📸</span>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-zinc-100">
|
||||
Identify a Plant Disease
|
||||
</h1>
|
||||
<p className="mt-3 text-zinc-500 dark:text-zinc-400 max-w-lg mx-auto">
|
||||
Upload a clear photo of the affected plant area. Our AI will analyze
|
||||
it and provide a detailed diagnosis with treatment recommendations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload component */}
|
||||
<ImageUpload onUpload={handleUpload} onError={handleError} />
|
||||
|
||||
{/* Tips */}
|
||||
<div className="mt-10 rounded-xl bg-zinc-50 dark:bg-zinc-800/50 px-5 py-4">
|
||||
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-3">
|
||||
Tips for best results
|
||||
</h2>
|
||||
<ul className="space-y-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
|
||||
Focus on the affected area — leaves, stems, or fruit showing symptoms
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
|
||||
Good lighting helps — natural daylight is ideal
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
|
||||
Include some healthy tissue for context and comparison
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
|
||||
Avoid blurry or overly distant shots
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
0
apps/web/src/components/.gitkeep
Normal file
0
apps/web/src/components/.gitkeep
Normal file
171
apps/web/src/components/ConfidenceBadge.test.tsx
Normal file
171
apps/web/src/components/ConfidenceBadge.test.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import ConfidenceBadge, { getConfidenceColors } from "@/components/ConfidenceBadge";
|
||||
import type { ConfidenceResult } from "@/lib/types";
|
||||
|
||||
describe("ConfidenceBadge", () => {
|
||||
function renderBadge(confidence: ConfidenceResult) {
|
||||
return render(<ConfidenceBadge confidence={confidence} />);
|
||||
}
|
||||
|
||||
describe("renders correct color for high confidence (≥0.8)", () => {
|
||||
it("uses green styling", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.85,
|
||||
adjusted: 0.87,
|
||||
label: "high",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
|
||||
expect(screen.getByText("87% confidence")).toBeInTheDocument();
|
||||
// Check for green color classes
|
||||
const badge = screen.getByRole("status");
|
||||
expect(badge).toHaveClass("bg-leaf-green-100");
|
||||
expect(badge).toHaveClass("text-leaf-green-800");
|
||||
});
|
||||
|
||||
it("shows checkmark icon for high confidence", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.9,
|
||||
adjusted: 0.92,
|
||||
label: "high",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
|
||||
expect(screen.getByText("92% confidence")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tooltip with high confidence explanation", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.85,
|
||||
adjusted: 0.87,
|
||||
label: "high",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
|
||||
const tooltip = document.querySelector('[role="tooltip"]');
|
||||
expect(tooltip?.textContent).toContain("High confidence");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renders correct color for medium confidence (≥0.5)", () => {
|
||||
it("uses amber styling", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.55,
|
||||
adjusted: 0.56,
|
||||
label: "medium",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
|
||||
expect(screen.getByText("56% confidence")).toBeInTheDocument();
|
||||
const badge = screen.getByRole("status");
|
||||
expect(badge).toHaveClass("bg-warning-amber-100");
|
||||
expect(badge).toHaveClass("text-warning-amber-800");
|
||||
});
|
||||
|
||||
it("renders tooltip with medium confidence explanation", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.55,
|
||||
adjusted: 0.56,
|
||||
label: "medium",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
|
||||
const tooltip = document.querySelector('[role="tooltip"]');
|
||||
expect(tooltip?.textContent).toContain("Medium confidence");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renders correct color for low confidence (<0.5)", () => {
|
||||
it("uses red styling", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.3,
|
||||
adjusted: 0.31,
|
||||
label: "low",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
|
||||
expect(screen.getByText("31% confidence")).toBeInTheDocument();
|
||||
const badge = screen.getByRole("status");
|
||||
expect(badge).toHaveClass("bg-red-100");
|
||||
expect(badge).toHaveClass("text-red-800");
|
||||
});
|
||||
|
||||
it("renders tooltip with low confidence explanation", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.3,
|
||||
adjusted: 0.31,
|
||||
label: "low",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
|
||||
const tooltip = document.querySelector('[role="tooltip"]');
|
||||
expect(tooltip?.textContent).toContain("Low confidence");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles exactly 0.8 threshold as high", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.78,
|
||||
adjusted: 0.8,
|
||||
label: "high",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
expect(screen.getByText("80% confidence")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles exactly 0.5 threshold as medium", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.49,
|
||||
adjusted: 0.5,
|
||||
label: "medium",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
expect(screen.getByText("50% confidence")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles 0% confidence", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0,
|
||||
adjusted: 0,
|
||||
label: "low",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
expect(screen.getByText("0% confidence")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles 100% confidence", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 1,
|
||||
adjusted: 1,
|
||||
label: "high",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
expect(screen.getByText("100% confidence")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConfidenceColors helper", () => {
|
||||
it("returns green colors for high", () => {
|
||||
const colors = getConfidenceColors("high");
|
||||
expect(colors.border).toContain("leaf-green");
|
||||
expect(colors.bg).toContain("leaf-green");
|
||||
expect(colors.accent).toContain("leaf-green");
|
||||
});
|
||||
|
||||
it("returns amber colors for medium", () => {
|
||||
const colors = getConfidenceColors("medium");
|
||||
expect(colors.border).toContain("warning-amber");
|
||||
expect(colors.bg).toContain("warning-amber");
|
||||
expect(colors.accent).toContain("warning-amber");
|
||||
});
|
||||
|
||||
it("returns red colors for low", () => {
|
||||
const colors = getConfidenceColors("low");
|
||||
expect(colors.border).toContain("red");
|
||||
expect(colors.bg).toContain("red");
|
||||
expect(colors.accent).toContain("red");
|
||||
});
|
||||
});
|
||||
});
|
||||
149
apps/web/src/components/ConfidenceBadge.tsx
Normal file
149
apps/web/src/components/ConfidenceBadge.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { ConfidenceResult } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Color-coded confidence indicator badge.
|
||||
*
|
||||
* - Green + checkmark for confidence ≥ 0.8 (high)
|
||||
* - Amber + warning for confidence ≥ 0.5 (medium)
|
||||
* - Red + exclamation for confidence < 0.5 (low)
|
||||
*
|
||||
* Shows percentage (e.g., "87% confidence") and a hover tooltip
|
||||
* explaining confidence interpretation.
|
||||
*/
|
||||
export default function ConfidenceBadge({
|
||||
confidence,
|
||||
className = "",
|
||||
}: {
|
||||
confidence: ConfidenceResult;
|
||||
className?: string;
|
||||
}) {
|
||||
const percentage = Math.round(confidence.adjusted * 100);
|
||||
const { colors, icon, tooltip } = getBadgeStyle(confidence.label);
|
||||
|
||||
return (
|
||||
<div className={`relative inline-block group ${className}`}>
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-semibold
|
||||
${colors.bg} ${colors.text} ${colors.ring}
|
||||
ring-1
|
||||
cursor-help select-none
|
||||
transition-colors duration-150
|
||||
`}
|
||||
role="status"
|
||||
aria-label={`${percentage}% confidence — ${confidence.label}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{percentage}% confidence</span>
|
||||
</span>
|
||||
|
||||
{/* Hover tooltip — appears on group hover */}
|
||||
<span
|
||||
className="
|
||||
pointer-events-none absolute -top-2 left-1/2 z-50 w-64 -translate-x-1/2 -translate-y-full
|
||||
rounded-lg bg-zinc-900 dark:bg-zinc-100 px-3 py-2 text-xs leading-relaxed text-zinc-100 dark:text-zinc-900
|
||||
opacity-0 shadow-lg transition-opacity duration-150
|
||||
group-hover:opacity-100
|
||||
"
|
||||
role="tooltip"
|
||||
>
|
||||
{tooltip}
|
||||
{/* Tooltip arrow */}
|
||||
<span className="absolute left-1/2 top-full h-0 w-0 -translate-x-1/2 border-4 border-transparent border-t-zinc-900 dark:border-t-zinc-100" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Style helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
interface BadgeStyle {
|
||||
colors: {
|
||||
bg: string;
|
||||
text: string;
|
||||
ring: string;
|
||||
};
|
||||
icon: React.ReactNode;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
function getBadgeStyle(label: "high" | "medium" | "low"): BadgeStyle {
|
||||
switch (label) {
|
||||
case "high":
|
||||
return {
|
||||
colors: {
|
||||
bg: "bg-leaf-green-100 dark:bg-leaf-green-900/50",
|
||||
text: "text-leaf-green-800 dark:text-leaf-green-200",
|
||||
ring: "ring-leaf-green-300 dark:ring-leaf-green-700",
|
||||
},
|
||||
icon: (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
tooltip: "High confidence: The model is very certain this matches the disease. Treat this as a strong diagnosis.",
|
||||
};
|
||||
|
||||
case "medium":
|
||||
return {
|
||||
colors: {
|
||||
bg: "bg-warning-amber-100 dark:bg-warning-amber-900/50",
|
||||
text: "text-warning-amber-800 dark:text-warning-amber-200",
|
||||
ring: "ring-warning-amber-300 dark:ring-warning-amber-700",
|
||||
},
|
||||
icon: (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
tooltip: "Medium confidence: The model is somewhat uncertain. Use symptom matching and visual inspection to confirm.",
|
||||
};
|
||||
|
||||
case "low":
|
||||
return {
|
||||
colors: {
|
||||
bg: "bg-red-100 dark:bg-red-900/50",
|
||||
text: "text-red-800 dark:text-red-200",
|
||||
ring: "ring-red-300 dark:ring-red-700",
|
||||
},
|
||||
icon: (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-6a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0V4.75A.75.75 0 0110 4zm0 10a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
tooltip: "Low confidence: The model is not confident in this match. Treat as a suggestion only and verify manually.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge colors for use in DiseaseCard borders/highlights.
|
||||
*/
|
||||
export function getConfidenceColors(label: "high" | "medium" | "low") {
|
||||
switch (label) {
|
||||
case "high":
|
||||
return {
|
||||
border: "border-leaf-green-400 dark:border-leaf-green-500",
|
||||
bg: "bg-leaf-green-50/50 dark:bg-leaf-green-950/20",
|
||||
accent: "bg-leaf-green-600 hover:bg-leaf-green-700",
|
||||
text: "text-leaf-green-700 dark:text-leaf-green-400",
|
||||
};
|
||||
case "medium":
|
||||
return {
|
||||
border: "border-warning-amber-400 dark:border-warning-amber-500",
|
||||
bg: "bg-warning-amber-50/50 dark:bg-warning-amber-950/20",
|
||||
accent: "bg-warning-amber-600 hover:bg-warning-amber-700",
|
||||
text: "text-warning-amber-700 dark:text-warning-amber-400",
|
||||
};
|
||||
case "low":
|
||||
return {
|
||||
border: "border-red-400 dark:border-red-500",
|
||||
bg: "bg-red-50/50 dark:bg-red-950/20",
|
||||
accent: "bg-red-600 hover:bg-red-700",
|
||||
text: "text-red-700 dark:text-red-400",
|
||||
};
|
||||
}
|
||||
}
|
||||
237
apps/web/src/components/DiseaseCard.test.tsx
Normal file
237
apps/web/src/components/DiseaseCard.test.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import DiseaseCard from "@/components/DiseaseCard";
|
||||
import type { PredictionResult, Disease, ConfidenceResult } from "@/lib/types";
|
||||
|
||||
// Mock the getLookalikeDiseases function
|
||||
vi.mock("@/lib/api/diseases", () => ({
|
||||
getLookalikeDiseases: vi.fn(() => []),
|
||||
getPlantById: vi.fn(() => ({ id: "tomato", commonName: "Tomato" })),
|
||||
}));
|
||||
|
||||
describe("DiseaseCard", () => {
|
||||
const mockDisease: Disease = {
|
||||
id: "early-blight",
|
||||
plantId: "tomato",
|
||||
name: "Early Blight",
|
||||
scientificName: "Alternaria solani",
|
||||
causalAgentType: "fungal",
|
||||
description: "Early blight is one of the most common fungal diseases affecting tomatoes. It primarily attacks lower and older leaves first, progressing upward.",
|
||||
symptoms: [
|
||||
"Dark brown spots with concentric rings on lower leaves",
|
||||
"Yellowing of leaves surrounding infected spots",
|
||||
"Premature defoliation starting from bottom of plant",
|
||||
],
|
||||
causes: [
|
||||
"Warm temperatures combined with high humidity",
|
||||
"Fungal spores overwintering in infected plant debris",
|
||||
],
|
||||
treatment: [
|
||||
"Remove and destroy all severely infected leaves immediately",
|
||||
"Apply copper-based fungicide spray every 7-10 days",
|
||||
"Improve air circulation by pruning lower leaves",
|
||||
],
|
||||
prevention: [
|
||||
"Practice 2-3 year crop rotation",
|
||||
"Water at soil level using drip irrigation",
|
||||
],
|
||||
lookalikeDiseaseIds: [],
|
||||
severity: "moderate",
|
||||
};
|
||||
|
||||
const mockConfidence: ConfidenceResult = {
|
||||
raw: 0.85,
|
||||
adjusted: 0.87,
|
||||
label: "high",
|
||||
};
|
||||
|
||||
const mockPrediction: PredictionResult = {
|
||||
diseaseId: "early-blight",
|
||||
disease: mockDisease,
|
||||
confidence: mockConfidence,
|
||||
lookalikes: [],
|
||||
};
|
||||
|
||||
function renderCard(prediction: PredictionResult, isPrimary = true) {
|
||||
return render(
|
||||
<DiseaseCard
|
||||
prediction={prediction}
|
||||
rank={1}
|
||||
isPrimary={isPrimary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("collapsed state", () => {
|
||||
it("shows disease name", () => {
|
||||
renderCard(mockPrediction);
|
||||
expect(screen.getByText("Early Blight")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows confidence badge", () => {
|
||||
renderCard(mockPrediction);
|
||||
expect(screen.getByText("87% confidence")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows causal agent type icon", () => {
|
||||
renderCard(mockPrediction);
|
||||
expect(screen.getByText("Fungal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows one-sentence summary", () => {
|
||||
renderCard(mockPrediction);
|
||||
// The summary appears as a line-clamp-2 paragraph
|
||||
const summary = document.querySelector('.line-clamp-2');
|
||||
expect(summary?.textContent).toContain("Early blight is one of the most common");
|
||||
});
|
||||
|
||||
it("shows scientific name", () => {
|
||||
renderCard(mockPrediction);
|
||||
expect(screen.getByText("Alternaria solani")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows rank number", () => {
|
||||
renderCard(mockPrediction);
|
||||
// Rank appears inside a rounded-lg div with font-bold
|
||||
const rankElements = document.querySelectorAll('.font-bold');
|
||||
expect(rankElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expanded state", () => {
|
||||
// Helper: find the card's expand/collapse button by its aria-controls attribute
|
||||
function getExpandButton() {
|
||||
return document.querySelector('button[aria-controls]') as HTMLButtonElement;
|
||||
}
|
||||
|
||||
it("expands on click", () => {
|
||||
renderCard(mockPrediction);
|
||||
const button = getExpandButton();
|
||||
expect(button).toBeTruthy();
|
||||
fireEvent.click(button!);
|
||||
|
||||
// After expanding, should show "Description" section header
|
||||
expect(screen.getByText("Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses on second click", () => {
|
||||
renderCard(mockPrediction, true); // start expanded
|
||||
const button = getExpandButton();
|
||||
expect(button).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
fireEvent.click(button!); // collapse
|
||||
|
||||
// Button should now show collapsed state
|
||||
expect(button).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
// The expandable body should have max-h-0
|
||||
const body = document.getElementById(`disease-card-body-${mockPrediction.diseaseId}`);
|
||||
expect(body).toHaveClass("max-h-0");
|
||||
});
|
||||
|
||||
it("primary card starts expanded", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
const button = getExpandButton();
|
||||
expect(button).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("non-primary card starts collapsed", () => {
|
||||
renderCard(mockPrediction, false);
|
||||
const button = getExpandButton();
|
||||
expect(button).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("shows symptom section when expanded", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Symptom Check")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows causes section when expanded", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Causes & Contributing Factors")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows treatment plan section when expanded", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Treatment Plan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows prevention tips section when expanded", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Prevention Tips")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("primary diagnosis highlight", () => {
|
||||
it("shows primary diagnosis ribbon", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Primary Diagnosis")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show ribbon for non-primary", () => {
|
||||
renderCard(mockPrediction, false);
|
||||
expect(screen.queryByText("Primary Diagnosis")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("feedback buttons", () => {
|
||||
it("shows feedback buttons when expanded", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Was this diagnosis helpful?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can click Yes feedback", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
|
||||
const yesButton = screen.getByRole("button", { name: /Yes/ });
|
||||
fireEvent.click(yesButton);
|
||||
|
||||
expect(screen.getByText("Thanks for your feedback!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can click No feedback", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
|
||||
const noButton = screen.getByRole("button", { name: /No/ });
|
||||
fireEvent.click(noButton);
|
||||
|
||||
expect(screen.getByText("Thanks for your feedback!")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dismiss functionality", () => {
|
||||
it("calls onDismiss when dismiss button is clicked", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<DiseaseCard
|
||||
prediction={mockPrediction}
|
||||
rank={1}
|
||||
isPrimary={true}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the dismiss button by aria-label
|
||||
const dismissBtn = screen.getByRole("button", { name: "Dismiss this result" });
|
||||
fireEvent.click(dismissBtn);
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("treatment urgency badges", () => {
|
||||
it("shows Immediate for first treatment step", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Immediate")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Within a week for second treatment step", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Within a week")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Ongoing for remaining treatment steps", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getAllByText("Ongoing").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
343
apps/web/src/components/DiseaseCard.tsx
Normal file
343
apps/web/src/components/DiseaseCard.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import type { PredictionResult, CausalAgentType } from "@/lib/types";
|
||||
import ConfidenceBadge, { getConfidenceColors } from "@/components/ConfidenceBadge";
|
||||
import SymptomChecker from "@/components/SymptomChecker";
|
||||
import TreatmentTimeline, { treatmentStepsWithUrgency } from "@/components/TreatmentTimeline";
|
||||
import LookalikeWarning from "@/components/LookalikeWarning";
|
||||
import { getLookalikeDiseases } from "@/lib/api/diseases";
|
||||
|
||||
/**
|
||||
* Individual disease result card with expandable sections.
|
||||
*
|
||||
* Collapsed state: disease name, confidence badge, causal agent type icon, one-sentence summary.
|
||||
* Expanded state: full description, symptom list, cause list, treatment timeline, prevention tips.
|
||||
* Smooth expand/collapse animation.
|
||||
* "Was this helpful?" feedback buttons at the bottom.
|
||||
*/
|
||||
export default function DiseaseCard({
|
||||
prediction,
|
||||
rank,
|
||||
isPrimary,
|
||||
onDismiss,
|
||||
}: {
|
||||
prediction: PredictionResult;
|
||||
rank: number;
|
||||
isPrimary: boolean;
|
||||
onDismiss?: () => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(isPrimary);
|
||||
const [feedback, setFeedback] = useState<"yes" | "no" | null>(null);
|
||||
|
||||
const { disease, confidence } = prediction;
|
||||
const colors = getConfidenceColors(confidence.label);
|
||||
const lookalikes = getLookalikeDiseases(disease.id);
|
||||
|
||||
const toggleExpand = useCallback(() => {
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
// One-sentence summary (first sentence of description)
|
||||
const summary = disease.description.split(".")[0] + ".";
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`
|
||||
group/card relative rounded-xl border-2 overflow-hidden transition-all duration-200
|
||||
${isPrimary
|
||||
? `${colors.border} ${colors.bg} shadow-md`
|
||||
: "border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-sm hover:shadow-md"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Primary diagnosis ribbon */}
|
||||
{isPrimary && (
|
||||
<div className={`${colors.accent} text-white text-xs font-bold uppercase tracking-wider px-4 py-1.5 flex items-center gap-2`}>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
Primary Diagnosis
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card header — clickable to expand/collapse */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpand}
|
||||
className="w-full px-4 pt-4 pb-2 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-green-500 focus-visible:ring-offset-2 rounded-t-xl"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={`disease-card-body-${disease.id}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Rank / causal agent icon */}
|
||||
<div className={`
|
||||
flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold
|
||||
${isPrimary
|
||||
? `${colors.accent} text-white`
|
||||
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400"
|
||||
}
|
||||
`}>
|
||||
{rank}
|
||||
</div>
|
||||
|
||||
{/* Disease info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{disease.name}
|
||||
</h3>
|
||||
<CausalAgentIcon type={disease.causalAgentType} />
|
||||
<ConfidenceBadge confidence={confidence} />
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs italic text-zinc-500 dark:text-zinc-400">
|
||||
{disease.scientificName}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2">
|
||||
{summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Expand/collapse chevron */}
|
||||
<svg
|
||||
className={`h-5 w-5 shrink-0 text-zinc-400 transition-transform duration-200 ${expanded ? "rotate-180" : ""}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.22 7.22a.75.75 0 011.06 0L10 10.94l3.72-3.72a.75.75 0 111.06 1.06l-4.25 4.25a.75.75 0 01-1.06 0L5.22 8.28a.75.75 0 010-1.06z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Card body — expandable content */}
|
||||
<div
|
||||
id={`disease-card-body-${disease.id}`}
|
||||
className={`
|
||||
overflow-hidden transition-all duration-300 ease-in-out
|
||||
${expanded ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0"}
|
||||
`}
|
||||
>
|
||||
<div className="px-4 pb-4 space-y-5">
|
||||
<hr className="border-zinc-200 dark:border-zinc-700" />
|
||||
|
||||
{/* Full description */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-1">
|
||||
Description
|
||||
</h4>
|
||||
<p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
|
||||
{disease.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Symptom checker */}
|
||||
<div>
|
||||
<SymptomChecker symptoms={disease.symptoms} />
|
||||
</div>
|
||||
|
||||
{/* Causes */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2 flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-zinc-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Causes & Contributing Factors
|
||||
</h4>
|
||||
<ul className="space-y-1.5" role="list">
|
||||
{disease.causes.map((cause, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-zinc-400 dark:bg-zinc-500" />
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400">{cause}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Treatment timeline */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2 flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-leaf-green-600 dark:text-leaf-green-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Treatment Plan
|
||||
</h4>
|
||||
<TreatmentTimeline
|
||||
steps={treatmentStepsWithUrgency(disease.treatment)}
|
||||
severity={disease.severity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Prevention tips */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2 flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-leaf-green-600 dark:text-leaf-green-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M6.32 2.577a49.255 49.255 0 0111.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 01-1.085.67L10 18.089l-9.165 3.583A.75.75 0 010 21V5.507c0-1.47 1.073-2.756 2.57-2.93a49.254 49.254 0 0111.36 0zM12 9a2 2 0 11-4 0 2 2 0 014 0zm-2 3a1 1 0 00-1 1v1a1 1 0 001 1h0a1 1 0 001-1v-1a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Prevention Tips
|
||||
</h4>
|
||||
<ul className="space-y-1.5" role="list">
|
||||
{disease.prevention.map((tip, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400">{tip}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Lookalike warnings */}
|
||||
{lookalikes.length > 0 && (
|
||||
<LookalikeWarning disease={disease} lookalikes={lookalikes} />
|
||||
)}
|
||||
|
||||
{/* Feedback buttons */}
|
||||
<div className="pt-2 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-2">
|
||||
Was this diagnosis helpful?
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedback("yes")}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium
|
||||
transition-colors
|
||||
${feedback === "yes"
|
||||
? "bg-leaf-green-100 dark:bg-leaf-green-900/50 text-leaf-green-700 dark:text-leaf-green-300 ring-1 ring-leaf-green-300 dark:ring-leaf-green-700"
|
||||
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
}
|
||||
`}
|
||||
aria-pressed={feedback === "yes"}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" />
|
||||
</svg>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedback("no")}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium
|
||||
transition-colors
|
||||
${feedback === "no"
|
||||
? "bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 ring-1 ring-red-300 dark:ring-red-700"
|
||||
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
}
|
||||
`}
|
||||
aria-pressed={feedback === "no"}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M18 10.5a1.5 1.5 0 11-3 0v-6a1.5 1.5 0 013 0v6zM14 10.667V5.236a2 2 0 00-1.105-1.795l-.05-.025A4 4 0 0011.057 2H5.641a2 2 0 00-1.962 1.608l-1.2 6A2 2 0 004.44 12H8v4a2 2 0 002 2 1 1 0 001-1v-.667a4 4 0 01.8-2.4l1.4-1.866a4 4 0 00.8-2.4z" />
|
||||
</svg>
|
||||
No
|
||||
</button>
|
||||
{feedback && (
|
||||
<span className="text-xs text-zinc-400 dark:text-zinc-500 ml-2">
|
||||
Thanks for your feedback!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button (top-right corner, visible on hover) */}
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="absolute top-3 right-3 z-10 rounded-lg p-1 text-zinc-400 opacity-0 transition-opacity hover:text-zinc-600 dark:hover:text-zinc-300 group-hover/card:opacity-100"
|
||||
aria-label="Dismiss this result"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Causal agent type icon — shows a small icon based on disease type.
|
||||
*/
|
||||
function CausalAgentIcon({ type }: { type: CausalAgentType }) {
|
||||
const config = getCausalAgentConfig(type);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium
|
||||
${config.bg} ${config.text}
|
||||
`}
|
||||
title={`${config.label} disease`}
|
||||
>
|
||||
{config.icon}
|
||||
<span className="hidden sm:inline">{config.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface CausalAgentConfig {
|
||||
label: string;
|
||||
bg: string;
|
||||
text: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
function getCausalAgentConfig(type: CausalAgentType): CausalAgentConfig {
|
||||
switch (type) {
|
||||
case "fungal":
|
||||
return {
|
||||
label: "Fungal",
|
||||
bg: "bg-purple-100 dark:bg-purple-900/50",
|
||||
text: "text-purple-700 dark:text-purple-300",
|
||||
icon: (
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<circle cx="8" cy="6" r="3" />
|
||||
<circle cx="5" cy="10" r="2" />
|
||||
<circle cx="11" cy="10" r="2" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
case "bacterial":
|
||||
return {
|
||||
label: "Bacterial",
|
||||
bg: "bg-blue-100 dark:bg-blue-900/50",
|
||||
text: "text-blue-700 dark:text-blue-300",
|
||||
icon: (
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<ellipse cx="8" cy="8" rx="5" ry="2.5" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
case "viral":
|
||||
return {
|
||||
label: "Viral",
|
||||
bg: "bg-pink-100 dark:bg-pink-900/50",
|
||||
text: "text-pink-700 dark:text-pink-300",
|
||||
icon: (
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="2.5" />
|
||||
<line x1="8" y1="1" x2="8" y2="4" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="8" y1="12" x2="8" y2="15" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="1" y1="8" x2="4" y2="8" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="12" y1="8" x2="15" y2="8" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
case "environmental":
|
||||
return {
|
||||
label: "Environmental",
|
||||
bg: "bg-orange-100 dark:bg-orange-900/50",
|
||||
text: "text-orange-700 dark:text-orange-300",
|
||||
icon: (
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 1a7 7 0 100 14A7 7 0 008 1zm0 2a1 1 0 011 1v2a1 1 0 01-2 0V4a1 1 0 011-1z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
63
apps/web/src/components/EmptyState.tsx
Normal file
63
apps/web/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
/** Emoji or SVG/icon string to display */
|
||||
illustration?: string;
|
||||
/** Primary heading text */
|
||||
title: string;
|
||||
/** Description / subtext */
|
||||
description?: string;
|
||||
/** Optional CTA button label (requires href) */
|
||||
actionLabel?: string;
|
||||
/** CTA button href */
|
||||
actionHref?: string;
|
||||
/** Optional className override */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable empty-state component with illustration, message, and optional CTA.
|
||||
* Used when search/filter returns no results or a list is empty.
|
||||
*/
|
||||
export default function EmptyState({
|
||||
illustration = "🔍",
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
actionHref,
|
||||
className = "",
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center py-16 px-6 text-center ${className}`}
|
||||
>
|
||||
<span
|
||||
className="text-6xl mb-6 block"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{illustration}
|
||||
</span>
|
||||
|
||||
<h3 className="text-xl font-semibold text-zinc-800 dark:text-zinc-200 mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{description && (
|
||||
<p className="text-zinc-500 dark:text-zinc-400 max-w-md leading-relaxed mb-6">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{actionLabel && actionHref && (
|
||||
<Link
|
||||
href={actionHref}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
{actionLabel}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
apps/web/src/components/ErrorBoundary.tsx
Normal file
101
apps/web/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import React, { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* React error boundary that catches rendering errors in its child tree.
|
||||
* Falls back to a friendly UI with error details (dev mode), a "Try again" button,
|
||||
* and a "Go home" link.
|
||||
*/
|
||||
export default class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// Log error to your error reporting service in production
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] px-6 py-16 text-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
{/* Warning illustration */}
|
||||
<div className="text-6xl mb-6" aria-hidden="true">
|
||||
🍂
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-zinc-900 dark:text-zinc-100 mb-3">
|
||||
Something went wrong!
|
||||
</h2>
|
||||
|
||||
<p className="text-zinc-600 dark:text-zinc-400 mb-6 leading-relaxed">
|
||||
A leaf must have fallen on the keyboard. Our team has been
|
||||
notified. Please try again or head back home.
|
||||
</p>
|
||||
|
||||
{/* Dev-mode error detail */}
|
||||
{process.env.NODE_ENV === "development" &&
|
||||
this.state.error && (
|
||||
<details className="mb-6 text-left bg-zinc-100 dark:bg-zinc-800 rounded-lg p-4 overflow-auto">
|
||||
<summary className="text-sm font-mono text-zinc-500 dark:text-zinc-400 cursor-pointer select-none">
|
||||
Error details (dev only)
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-red-600 dark:text-red-400 whitespace-pre-wrap font-mono">
|
||||
{this.state.error.message}
|
||||
{"\n\n"}
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={() =>
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
🔄 Try again
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-zinc-300 dark:border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
🏠 Go home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
79
apps/web/src/components/Footer.tsx
Normal file
79
apps/web/src/components/Footer.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { APP_NAME, APP_TAGLINE, NAV_LINKS, BETA_DISCLAIMER } from "@/lib/constants";
|
||||
|
||||
/**
|
||||
* Site footer with three-column layout:
|
||||
* about blurb, quick links, and legal disclaimer.
|
||||
*/
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="mt-auto border-t border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* About blurb */}
|
||||
<div>
|
||||
<Link href="/" className="flex items-center gap-2 text-lg font-bold text-leaf-green-700 dark:text-leaf-green-400">
|
||||
<span aria-hidden="true">🌱</span>
|
||||
{APP_NAME}
|
||||
</Link>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
|
||||
{APP_TAGLINE} — Upload a photo of your plant and get a
|
||||
hyper-specific disease diagnosis with treatment steps and prevention
|
||||
tips. Built by gardeners, for gardeners.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 uppercase tracking-wide">
|
||||
Quick Links
|
||||
</h3>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-sm text-zinc-600 hover:text-leaf-green-700 dark:text-zinc-400 dark:hover:text-leaf-green-300 transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 uppercase tracking-wide">
|
||||
⚠️ Disclaimer
|
||||
</h3>
|
||||
<p className="mt-4 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
|
||||
{BETA_DISCLAIMER}
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-medium text-warning-amber-600 dark:text-warning-amber-400">
|
||||
Beta — in active development.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="mt-10 pt-6 border-t border-zinc-200 dark:border-zinc-800 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
© {new Date().getFullYear()} {APP_NAME}. Made by gardeners, for gardeners. 🌻
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<Link
|
||||
href="/about"
|
||||
className="hover:text-leaf-green-700 dark:hover:text-leaf-green-300 transition-colors"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Open source ❤️</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
543
apps/web/src/components/ImageUpload.tsx
Normal file
543
apps/web/src/components/ImageUpload.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
DragEvent,
|
||||
ChangeEvent,
|
||||
} from "react";
|
||||
import { uploadImage, UploadResponse } from "@/lib/api/upload";
|
||||
import { validateImageFile } from "@/lib/image-processing";
|
||||
import LoadingSkeleton, { UploadSkeleton } from "@/components/LoadingSkeleton";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type UploadStatus = "idle" | "validating" | "uploading" | "success" | "error";
|
||||
|
||||
export interface ImageUploadProps {
|
||||
/** Called when upload succeeds with the server response */
|
||||
onUpload?: (response: UploadResponse) => void;
|
||||
/** Called when upload fails with the error */
|
||||
onError?: (error: string) => void;
|
||||
/** Optional class for the root element */
|
||||
className?: string;
|
||||
/** Optional: disable the component */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ─── SVG Icons (inline, no dependencies) ─────────────────────────────────────
|
||||
|
||||
function UploadCloudIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 16V4m0 0l-4 4m4-4l4 4" />
|
||||
<path d="M20 16.5c0 .83-.97 1.5-2.17 1.5S16 17.33 16 16.5c0-.35.16-.66.42-.87C16.37 14.97 17.5 14 19 14c1.5 0 2.63.97 2.98 2.63.26.21.42.52.42.87Z" />
|
||||
<path d="M4 16.5c0 .83.97 1.5 2.17 1.5S8 17.33 8 16.5c0-.35-.16-.66-.42-.87C7.63 14.97 6.5 14 5 14c-1.5 0-2.63.97-2.98 2.63C1.76 16.91 2 17.17 2 17.5Z" />
|
||||
<path d="M12 13c-3 0-5.5 1.3-7 3.3" />
|
||||
<path d="M12 13c3 0 5.5 1.3 7 3.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SpinnerIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={`animate-spin ${className}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RetryIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M1 4v6h6" />
|
||||
<path d="M3.51 15a9 9 0 105.64-8.36L1 10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Progress Bar ────────────────────────────────────────────────────────────
|
||||
|
||||
function ProgressBar({ progress }: { progress: number }) {
|
||||
return (
|
||||
<div className="w-full mt-4" role="progressbar" aria-valuenow={progress} aria-valuemin={0} aria-valuemax={100}>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-leaf-green-500 transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-1 block text-center text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function ImageUpload({
|
||||
onUpload,
|
||||
onError,
|
||||
className = "",
|
||||
disabled = false,
|
||||
}: ImageUploadProps) {
|
||||
const [status, setStatus] = useState<UploadStatus>("idle");
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [uploadResponse, setUploadResponse] = useState<UploadResponse | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const fileHandleRef = useRef<File | null>(null);
|
||||
|
||||
// Simulate progress during upload (fetch doesn't support upload progress natively)
|
||||
const simulateProgress = useCallback(() => {
|
||||
setProgress(0);
|
||||
const interval = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 90) {
|
||||
clearInterval(interval);
|
||||
return 90;
|
||||
}
|
||||
return prev + Math.random() * 15;
|
||||
});
|
||||
}, 200);
|
||||
return interval;
|
||||
}, []);
|
||||
|
||||
// Handle file selection
|
||||
const handleFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (disabled) return;
|
||||
|
||||
// Reset state
|
||||
setStatus("validating");
|
||||
setErrorMessage(null);
|
||||
setProgress(0);
|
||||
setUploadResponse(null);
|
||||
|
||||
// Validate
|
||||
const validation = validateImageFile(file);
|
||||
if (!validation.ok) {
|
||||
setStatus("error");
|
||||
setErrorMessage(validation.error);
|
||||
onError?.(validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate preview
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
setFileName(file.name);
|
||||
fileHandleRef.current = file;
|
||||
|
||||
// Upload
|
||||
setStatus("uploading");
|
||||
const progressInterval = simulateProgress();
|
||||
|
||||
try {
|
||||
const response = await uploadImage(file, (pct) => {
|
||||
setProgress(Math.min(pct, 95));
|
||||
});
|
||||
clearInterval(progressInterval);
|
||||
setProgress(100);
|
||||
setStatus("success");
|
||||
setUploadResponse(response);
|
||||
onUpload?.(response);
|
||||
} catch (err) {
|
||||
clearInterval(progressInterval);
|
||||
const msg =
|
||||
err instanceof Error ? err.message : "Upload failed unexpectedly.";
|
||||
setStatus("error");
|
||||
setErrorMessage(msg);
|
||||
onError?.(msg);
|
||||
}
|
||||
},
|
||||
[disabled, onUpload, onError, simulateProgress],
|
||||
);
|
||||
|
||||
// Drag handlers
|
||||
const handleDragEnter = useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) setIsDragOver(true);
|
||||
}, [disabled]);
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
},
|
||||
[handleFile],
|
||||
);
|
||||
|
||||
// File input change handler
|
||||
const handleFileChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
// Reset input so same file can be selected again
|
||||
e.target.value = "";
|
||||
},
|
||||
[handleFile],
|
||||
);
|
||||
|
||||
// Click handler for drop zone
|
||||
const handleClick = useCallback(() => {
|
||||
if (!disabled) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
// Clear / reset
|
||||
const handleClear = useCallback(() => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
setStatus("idle");
|
||||
setPreviewUrl(null);
|
||||
setFileName(null);
|
||||
setErrorMessage(null);
|
||||
setProgress(0);
|
||||
setUploadResponse(null);
|
||||
fileHandleRef.current = null;
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}, [previewUrl]);
|
||||
|
||||
// Retry with same file
|
||||
const handleRetry = useCallback(() => {
|
||||
if (fileHandleRef.current) {
|
||||
handleFile(fileHandleRef.current);
|
||||
}
|
||||
}, [handleFile]);
|
||||
|
||||
// ─── Render States ────────────────────────────────────────────────────────
|
||||
|
||||
// Error state
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center rounded-2xl border-2 border-warning-amber-300 dark:border-warning-amber-700 bg-warning-amber-50/50 dark:bg-warning-amber-950/20 p-8 text-center ${className}`}
|
||||
>
|
||||
{previewUrl && (
|
||||
<div className="relative mb-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Selected image preview"
|
||||
className="h-48 w-48 rounded-xl object-cover ring-2 ring-warning-amber-300 dark:ring-warning-amber-700"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="rounded-full bg-red-500/80 p-2">
|
||||
<XIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-warning-amber-700 dark:text-warning-amber-400">
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Upload Failed</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400 max-w-sm">
|
||||
{errorMessage || "Something went wrong while uploading your image."}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-leaf-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
<RetryIcon className="h-4 w-4" />
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-300 dark:border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Uploading / validating state
|
||||
if (status === "uploading" || status === "validating") {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center rounded-2xl border-2 border-leaf-green-300 dark:border-leaf-green-700 bg-white dark:bg-zinc-900 p-8 text-center ${className}`}
|
||||
>
|
||||
{previewUrl && (
|
||||
<div className="relative mb-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Uploading image preview"
|
||||
className="h-48 w-48 rounded-xl object-cover ring-2 ring-leaf-green-300 dark:ring-leaf-green-700"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/20">
|
||||
<SpinnerIcon className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-leaf-green-700 dark:text-leaf-green-400">
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">
|
||||
{status === "validating" ? "Validating image..." : "Uploading..."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{fileName && (
|
||||
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400 truncate max-w-xs">
|
||||
{fileName}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ProgressBar progress={Math.round(progress)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (status === "success" && uploadResponse) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center rounded-2xl border-2 border-leaf-green-400 dark:border-leaf-green-600 bg-leaf-green-50/50 dark:bg-leaf-green-950/20 p-8 text-center ${className}`}
|
||||
>
|
||||
{previewUrl && (
|
||||
<div className="relative mb-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Uploaded image"
|
||||
className="h-48 w-48 rounded-xl object-cover ring-2 ring-leaf-green-400 dark:ring-leaf-green-600"
|
||||
/>
|
||||
<div className="absolute -right-1 -top-1 flex h-7 w-7 items-center justify-center rounded-full bg-leaf-green-500 shadow">
|
||||
<CheckIcon className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-leaf-green-700 dark:text-leaf-green-400">
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">Upload Successful</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 w-full max-w-xs space-y-1 rounded-lg bg-white/60 dark:bg-zinc-900/60 px-4 py-3 text-left">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Image ID</span>
|
||||
<span className="font-mono text-zinc-700 dark:text-zinc-300 truncate ml-2">
|
||||
{uploadResponse.imageId.slice(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Tensor Shape</span>
|
||||
<span className="font-mono text-zinc-700 dark:text-zinc-300">
|
||||
[{uploadResponse.tensorShape.join(", ")}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="mt-4 inline-flex items-center gap-1.5 rounded-lg border border-zinc-300 dark:border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
Upload Another
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Idle / default state (drop zone)
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Upload a plant image. Drag and drop or click to browse."
|
||||
className={`
|
||||
relative flex flex-col items-center justify-center
|
||||
rounded-2xl border-2 border-dashed p-8 text-center transition-all duration-200
|
||||
cursor-pointer select-none
|
||||
${isDragOver
|
||||
? "border-leaf-green-500 bg-leaf-green-50 dark:border-leaf-green-400 dark:bg-leaf-green-950/30 scale-[1.02]"
|
||||
: "border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 hover:border-leaf-green-400 dark:hover:border-leaf-green-600 hover:bg-leaf-green-50/50 dark:hover:bg-leaf-green-950/10"
|
||||
}
|
||||
${disabled ? "opacity-50 pointer-events-none" : ""}
|
||||
${className}
|
||||
`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/webp"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Upload icon */}
|
||||
<div className={`flex h-16 w-16 items-center justify-center rounded-2xl transition-colors ${
|
||||
isDragOver
|
||||
? "bg-leaf-green-100 dark:bg-leaf-green-900/50"
|
||||
: "bg-zinc-100 dark:bg-zinc-800"
|
||||
}`}>
|
||||
<UploadCloudIcon className="h-8 w-8 text-leaf-green-600 dark:text-leaf-green-400" />
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<h3 className="mt-4 text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{isDragOver ? "Drop your image here" : "Upload a Plant Photo"}
|
||||
</h3>
|
||||
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Drag and drop, or{" "}
|
||||
<span className="font-medium text-leaf-green-600 dark:text-leaf-green-400 underline underline-offset-2">
|
||||
click to browse
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* File type hints */}
|
||||
<div className="mt-4 flex items-center gap-3 text-xs text-zinc-400 dark:text-zinc-500">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-100 dark:bg-zinc-800 px-2.5 py-1">
|
||||
<ImageIcon className="h-3 w-3" />
|
||||
PNG, JPG, WebP
|
||||
</span>
|
||||
<span>Max 10 MB</span>
|
||||
<span>Min 150×150</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
apps/web/src/components/LoadingSkeleton.tsx
Normal file
126
apps/web/src/components/LoadingSkeleton.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
type SkeletonVariant = "card" | "text" | "image" | "circle" | "row";
|
||||
|
||||
interface LoadingSkeletonProps {
|
||||
variant?: SkeletonVariant;
|
||||
count?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable loading skeleton with pulse animation.
|
||||
* Supports variants: card, text, image, circle, row.
|
||||
* Accepts a `count` to render multiple skeleton items.
|
||||
*/
|
||||
export default function LoadingSkeleton({
|
||||
variant = "text",
|
||||
count = 1,
|
||||
className = "",
|
||||
}: LoadingSkeletonProps) {
|
||||
const skeletonClass = `animate-pulse rounded bg-zinc-200 dark:bg-zinc-700 ${className}`;
|
||||
|
||||
const renderSkeleton = (index: number) => {
|
||||
switch (variant) {
|
||||
case "card":
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-xl border border-zinc-200 dark:border-zinc-700 overflow-hidden"
|
||||
>
|
||||
<div className={`${skeletonClass} h-48 w-full rounded-none`} />
|
||||
<div className="p-4 space-y-3">
|
||||
<div className={`${skeletonClass} h-5 w-3/4`} />
|
||||
<div className={`${skeletonClass} h-4 w-1/2`} />
|
||||
<div className={`${skeletonClass} h-4 w-full`} />
|
||||
<div className={`${skeletonClass} h-4 w-2/3`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "text":
|
||||
return (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className={`${skeletonClass} h-4 w-full`} />
|
||||
<div className={`${skeletonClass} h-4 w-5/6`} />
|
||||
<div className={`${skeletonClass} h-4 w-4/6`} />
|
||||
</div>
|
||||
);
|
||||
case "image":
|
||||
return (
|
||||
<div key={index} className={`${skeletonClass} h-48 w-full`} />
|
||||
);
|
||||
case "circle":
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`${skeletonClass} h-16 w-16 rounded-full`}
|
||||
/>
|
||||
);
|
||||
case "row":
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-4">
|
||||
<div className={`${skeletonClass} h-12 w-12 rounded-lg shrink-0`} />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className={`${skeletonClass} h-4 w-1/3`} />
|
||||
<div className={`${skeletonClass} h-3 w-2/3`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }, (_, i) => renderSkeleton(i))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Preset skeletons ─── */
|
||||
|
||||
/**
|
||||
* Full-page results skeleton: image placeholder + text blocks.
|
||||
*/
|
||||
export function ResultsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-8" role="status" aria-label="Loading results">
|
||||
<LoadingSkeleton variant="image" className="h-64 rounded-xl" />
|
||||
<LoadingSkeleton variant="text" count={3} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<LoadingSkeleton variant="card" count={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plant card grid skeleton.
|
||||
*/
|
||||
export function PlantCardSkeleton({ count = 6 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<LoadingSkeleton variant="card" count={count} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload area skeleton.
|
||||
*/
|
||||
export function UploadSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center border-2 border-dashed border-zinc-300 dark:border-zinc-600 rounded-2xl p-12 space-y-4"
|
||||
role="status"
|
||||
aria-label="Loading upload area"
|
||||
>
|
||||
<LoadingSkeleton variant="circle" className="h-20 w-20" />
|
||||
<LoadingSkeleton variant="text" className="w-1/2" />
|
||||
<LoadingSkeleton variant="text" className="w-1/3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
apps/web/src/components/LookalikeWarning.test.tsx
Normal file
168
apps/web/src/components/LookalikeWarning.test.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import LookalikeWarning from "@/components/LookalikeWarning";
|
||||
import type { Disease } from "@/lib/types";
|
||||
|
||||
describe("LookalikeWarning", () => {
|
||||
const mockDisease: Disease = {
|
||||
id: "early-blight",
|
||||
plantId: "tomato",
|
||||
name: "Early Blight",
|
||||
scientificName: "Alternaria solani",
|
||||
causalAgentType: "fungal",
|
||||
description: "Early blight is a common fungal disease.",
|
||||
symptoms: [
|
||||
"Dark brown spots with concentric rings on lower leaves",
|
||||
"Yellowing of leaves surrounding infected spots",
|
||||
"Premature defoliation starting from bottom of plant",
|
||||
],
|
||||
causes: ["Warm temperatures with high humidity"],
|
||||
treatment: ["Remove infected leaves", "Apply fungicide"],
|
||||
prevention: ["Practice crop rotation"],
|
||||
lookalikeDiseaseIds: ["late-blight"],
|
||||
severity: "moderate",
|
||||
};
|
||||
|
||||
const mockLookalike: Disease = {
|
||||
id: "late-blight",
|
||||
plantId: "tomato",
|
||||
name: "Late Blight",
|
||||
scientificName: "Phytophthora infestans",
|
||||
causalAgentType: "fungal",
|
||||
description: "Late blight is a devastating oomycete disease.",
|
||||
symptoms: [
|
||||
"Large irregular dark green to black water-soaked lesions on leaves",
|
||||
"Yellowing of leaves surrounding infected spots",
|
||||
"Rapid browning and death of entire leaves and stems",
|
||||
],
|
||||
causes: ["Cool temperatures with prolonged leaf wetness"],
|
||||
treatment: ["Remove and destroy infected material", "Apply mancozeb fungicide"],
|
||||
prevention: ["Plant resistant varieties"],
|
||||
lookalikeDiseaseIds: ["early-blight"],
|
||||
severity: "critical",
|
||||
};
|
||||
|
||||
function renderWarning(disease: Disease, lookalikes: Disease[]) {
|
||||
return render(<LookalikeWarning disease={disease} lookalikes={lookalikes} />);
|
||||
}
|
||||
|
||||
describe("renders nothing when no lookalikes", () => {
|
||||
it("returns null for empty lookalikes array", () => {
|
||||
const { container } = renderWarning(mockDisease, []);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("banner header", () => {
|
||||
it("shows warning message with lookalike name", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
expect(screen.getByText(/easily confused with/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Late Blight")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows warning icon", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
// Warning icon is an SVG with specific path
|
||||
const svg = document.querySelector('svg[aria-hidden="true"]');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("expand/collapse", () => {
|
||||
it("shows collapsed state by default", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
// Comparison table should not be visible
|
||||
expect(screen.queryByText("Early Blight vs. Late Blight")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands comparison on click", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
const button = screen.getByRole("button", { expanded: false });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText("Early Blight vs. Late Blight")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses comparison on second click", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button); // expand
|
||||
fireEvent.click(button); // collapse
|
||||
|
||||
expect(screen.queryByText("Early Blight vs. Late Blight")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles chevron direction", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
const button = screen.getByRole("button", { expanded: false });
|
||||
expect(button).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(button).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("comparison table", () => {
|
||||
it("shows comparison table header with disease names", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText("Symptom")).toBeInTheDocument();
|
||||
// Disease names appear in table headers (th elements)
|
||||
const headers = document.querySelectorAll("th");
|
||||
const headerText = Array.from(headers).map((h) => h.textContent);
|
||||
expect(headerText).toContain("Early Blight");
|
||||
expect(headerText).toContain("Late Blight");
|
||||
});
|
||||
|
||||
it("shows 'Present' for symptoms shared by both diseases", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
// "Yellowing of leaves surrounding infected spots" is in both
|
||||
const presentSpans = screen.getAllByText("Present");
|
||||
expect(presentSpans.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows legend for present/similar indicators", () => {
|
||||
renderWarning(mockDisease, [mockLookalike]);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText("Present in both")).toBeInTheDocument();
|
||||
expect(screen.getByText("Similar symptom")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple lookalikes", () => {
|
||||
it("shows all lookalike names in banner", () => {
|
||||
const lookalike2: Disease = {
|
||||
...mockLookalike,
|
||||
id: "septoria-leaf-spot",
|
||||
name: "Septoria Leaf Spot",
|
||||
symptoms: ["Small circular spots with dark borders"],
|
||||
};
|
||||
|
||||
renderWarning(mockDisease, [mockLookalike, lookalike2]);
|
||||
expect(screen.getByText(/easily confused with/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows comparison for each lookalike when expanded", () => {
|
||||
const lookalike2: Disease = {
|
||||
...mockLookalike,
|
||||
id: "septoria-leaf-spot",
|
||||
name: "Septoria Leaf Spot",
|
||||
symptoms: ["Small circular spots with dark borders"],
|
||||
};
|
||||
|
||||
renderWarning(mockDisease, [mockLookalike, lookalike2]);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText("Early Blight vs. Late Blight")).toBeInTheDocument();
|
||||
expect(screen.getByText("Early Blight vs. Septoria Leaf Spot")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
185
apps/web/src/components/LookalikeWarning.tsx
Normal file
185
apps/web/src/components/LookalikeWarning.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import type { Disease } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Warning banner when lookalike diseases exist, with side-by-side comparison toggle.
|
||||
*
|
||||
* Yellow banner: "This disease is easily confused with [lookalike name]."
|
||||
* Click to expand side-by-side symptom comparison table.
|
||||
* Comparison table columns: symptom, this disease, lookalike disease.
|
||||
* Links to lookalike disease detail.
|
||||
*/
|
||||
export default function LookalikeWarning({
|
||||
disease,
|
||||
lookalikes,
|
||||
}: {
|
||||
/** The current predicted disease */
|
||||
disease: Disease;
|
||||
/** Array of lookalike disease objects */
|
||||
lookalikes: Disease[];
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (lookalikes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-warning-amber-200 dark:border-warning-amber-800 bg-warning-amber-50 dark:bg-warning-amber-950/30 overflow-hidden">
|
||||
{/* Banner header — clickable to expand */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-warning-amber-100/50 dark:hover:bg-warning-amber-900/20 transition-colors"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{/* Warning icon */}
|
||||
<svg className="h-5 w-5 shrink-0 text-warning-amber-600 dark:text-warning-amber-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
|
||||
{/* Warning text */}
|
||||
<span className="text-sm font-medium text-warning-amber-800 dark:text-warning-amber-200 flex-1">
|
||||
This disease is easily confused with{" "}
|
||||
{lookalikes.map((d, i) => (
|
||||
<span key={d.id}>
|
||||
{i > 0 ? ", " : ""}
|
||||
<span className="font-semibold underline decoration-warning-amber-400 dark:decoration-warning-amber-600 underline-offset-2">
|
||||
{d.name}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{lookalikes.length > 1 ? "s" : ""}.
|
||||
</span>
|
||||
|
||||
{/* Expand/collapse chevron */}
|
||||
<svg
|
||||
className={`h-4 w-4 shrink-0 text-warning-amber-500 transition-transform duration-200 ${expanded ? "rotate-180" : ""}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.22 7.22a.75.75 0 011.06 0L10 10.94l3.72-3.72a.75.75 0 111.06 1.06l-4.25 4.25a.75.75 0 01-1.06 0L5.22 8.28a.75.75 0 010-1.06z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Expanded comparison table */}
|
||||
{expanded && (
|
||||
<div className="border-t border-warning-amber-200 dark:border-warning-amber-800 px-4 py-4 space-y-4">
|
||||
{lookalikes.map((lookalike) => (
|
||||
<LookalikeComparison
|
||||
key={lookalike.id}
|
||||
disease={disease}
|
||||
lookalike={lookalike}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Side-by-side comparison of symptoms between the predicted disease and a lookalike.
|
||||
*/
|
||||
function LookalikeComparison({
|
||||
disease,
|
||||
lookalike,
|
||||
}: {
|
||||
disease: Disease;
|
||||
lookalike: Disease;
|
||||
}) {
|
||||
// Build a combined list of symptoms for comparison
|
||||
const allSymptoms = [...new Set([...disease.symptoms, ...lookalike.symptoms])];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-warning-amber-900 dark:text-warning-amber-100 mb-2">
|
||||
{disease.name} vs. {lookalike.name}
|
||||
</h5>
|
||||
|
||||
{/* Comparison table */}
|
||||
<div className="overflow-x-auto rounded-lg border border-warning-amber-200 dark:border-warning-amber-800">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-warning-amber-100 dark:bg-warning-amber-900/40">
|
||||
<th className="px-3 py-2 text-left font-semibold text-warning-amber-900 dark:text-warning-amber-100 w-1/4">
|
||||
Symptom
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-warning-amber-900 dark:text-warning-amber-100 w-1/3">
|
||||
{disease.name}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-warning-amber-900 dark:text-warning-amber-100 w-1/3">
|
||||
{lookalike.name}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-zinc-900 divide-y divide-warning-amber-100 dark:divide-warning-amber-900/30">
|
||||
{allSymptoms.map((symptom, i) => {
|
||||
const hasSymptom = disease.symptoms.includes(symptom);
|
||||
const lookalikeHasSymptom = lookalike.symptoms.includes(symptom);
|
||||
// Check if it's a similar (but not exact) symptom
|
||||
const lookalikeSimilar = !lookalikeHasSymptom && lookalike.symptoms.some(
|
||||
ls => ls.toLowerCase().includes(symptom.toLowerCase().slice(0, 10))
|
||||
);
|
||||
|
||||
return (
|
||||
<tr key={i} className="hover:bg-warning-amber-50/50 dark:hover:bg-warning-amber-900/10">
|
||||
<td className="px-3 py-2 text-zinc-700 dark:text-zinc-300 align-top">
|
||||
{symptom.slice(0, 60)}
|
||||
{symptom.length > 60 ? "…" : ""}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{hasSymptom ? (
|
||||
<span className="inline-flex items-center gap-1 text-leaf-green-700 dark:text-leaf-green-400">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Present
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-zinc-400 dark:text-zinc-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{lookalikeHasSymptom ? (
|
||||
<span className="inline-flex items-center gap-1 text-leaf-green-700 dark:text-leaf-green-400">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Present
|
||||
</span>
|
||||
) : lookalikeSimilar ? (
|
||||
<span className="inline-flex items-center gap-1 text-warning-amber-700 dark:text-warning-amber-400">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Similar
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-zinc-400 dark:text-zinc-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Key differences summary */}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-leaf-green-500" />
|
||||
Present in both
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-warning-amber-500" />
|
||||
Similar symptom
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
287
apps/web/src/components/Navbar.tsx
Normal file
287
apps/web/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { APP_NAME, NAV_LINKS } from "@/lib/constants";
|
||||
|
||||
/**
|
||||
* Responsive global navigation bar.
|
||||
* - Sticky top bar with app name and nav links
|
||||
* - Mobile hamburger menu with slide-out drawer
|
||||
* - Search input that navigates to /browse?search=... on submit
|
||||
* - Active link highlighting based on current route
|
||||
*/
|
||||
export default function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const drawerRef = useRef<HTMLDivElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setMobileOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Trap focus inside mobile drawer when open
|
||||
useEffect(() => {
|
||||
if (mobileOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
const isActive = useCallback(
|
||||
(href: string) => {
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname.startsWith(href);
|
||||
},
|
||||
[pathname]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = searchQuery.trim();
|
||||
if (q) {
|
||||
router.push(`/browse?search=${encodeURIComponent(q)}`);
|
||||
} else {
|
||||
router.push("/browse");
|
||||
}
|
||||
setMobileOpen(false);
|
||||
},
|
||||
[searchQuery, router]
|
||||
);
|
||||
|
||||
const navLinkClass = (href: string) =>
|
||||
`text-sm font-medium transition-colors px-3 py-2 rounded-lg ${
|
||||
isActive(href)
|
||||
? "bg-leaf-green-100 text-leaf-green-800 dark:bg-leaf-green-900/40 dark:text-leaf-green-300"
|
||||
: "text-zinc-600 hover:text-leaf-green-700 hover:bg-leaf-green-50 dark:text-zinc-400 dark:hover:text-leaf-green-300 dark:hover:bg-leaf-green-900/20"
|
||||
}`;
|
||||
|
||||
const mobileNavLinkClass = (href: string) =>
|
||||
`block text-base font-medium transition-colors px-4 py-3 rounded-lg ${
|
||||
isActive(href)
|
||||
? "bg-leaf-green-100 text-leaf-green-800 dark:bg-leaf-green-900/40 dark:text-leaf-green-300"
|
||||
: "text-zinc-700 hover:text-leaf-green-700 hover:bg-leaf-green-50 dark:text-zinc-300 dark:hover:text-leaf-green-300 dark:hover:bg-leaf-green-900/20"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-zinc-200 dark:border-zinc-800 bg-white/95 dark:bg-zinc-950/95 backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:supports-[backdrop-filter]:bg-zinc-950/80">
|
||||
<nav
|
||||
className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8"
|
||||
aria-label="Global"
|
||||
>
|
||||
{/* Logo / App Name */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-lg font-bold tracking-tight text-leaf-green-700 dark:text-leaf-green-400 shrink-0"
|
||||
>
|
||||
<span aria-hidden="true" className="text-2xl">
|
||||
🌱
|
||||
</span>
|
||||
<span className="hidden sm:inline">{APP_NAME}</span>
|
||||
<span className="sm:hidden">{APP_NAME}</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav links */}
|
||||
<div className="hidden md:flex md:items-center md:gap-1">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<Link key={link.href} href={link.href} className={navLinkClass(link.href)}>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop search form */}
|
||||
<form
|
||||
onSubmit={handleSearch}
|
||||
className="hidden md:flex items-center gap-2"
|
||||
role="search"
|
||||
>
|
||||
<div className="relative">
|
||||
<label htmlFor="navbar-search" className="sr-only">
|
||||
Search plants and diseases
|
||||
</label>
|
||||
<input
|
||||
id="navbar-search"
|
||||
type="search"
|
||||
placeholder="Search plants..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-56 lg:w-64 rounded-lg border border-zinc-300 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800 px-3.5 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-all"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Mobile hamburger button */}
|
||||
<button
|
||||
ref={toggleRef}
|
||||
type="button"
|
||||
className="md:hidden inline-flex items-center justify-center rounded-lg p-2 text-zinc-600 hover:text-leaf-green-700 hover:bg-leaf-green-50 dark:text-zinc-400 dark:hover:text-leaf-green-300 dark:hover:bg-leaf-green-900/20 transition-colors"
|
||||
onClick={() => setMobileOpen((prev) => !prev)}
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-menu"
|
||||
aria-label={mobileOpen ? "Close navigation menu" : "Open navigation menu"}
|
||||
>
|
||||
{mobileOpen ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="4" x2="20" y1="12" y2="12" />
|
||||
<line x1="4" x2="20" y1="6" y2="6" />
|
||||
<line x1="4" x2="20" y1="18" y2="18" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Mobile drawer overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm md:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<div
|
||||
ref={drawerRef}
|
||||
id="mobile-menu"
|
||||
className={`fixed top-0 right-0 z-50 h-full w-72 max-w-[85vw] bg-white dark:bg-zinc-900 border-l border-zinc-200 dark:border-zinc-800 shadow-2xl transform transition-transform duration-300 ease-in-out md:hidden ${
|
||||
mobileOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 h-16 border-b border-zinc-200 dark:border-zinc-800">
|
||||
<span className="text-lg font-bold text-leaf-green-700 dark:text-leaf-green-400">
|
||||
🌱 {APP_NAME}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg p-2 text-zinc-600 hover:text-leaf-green-700 hover:bg-leaf-green-50 dark:text-zinc-400 dark:hover:text-leaf-green-300 dark:hover:bg-leaf-green-900/20 transition-colors"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
aria-label="Close navigation menu"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pt-6 space-y-1">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={mobileNavLinkClass(link.href)}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile search */}
|
||||
<div className="px-4 mt-6 pt-6 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<form onSubmit={handleSearch} role="search">
|
||||
<label htmlFor="mobile-search" className="sr-only">
|
||||
Search plants and diseases
|
||||
</label>
|
||||
<input
|
||||
id="mobile-search"
|
||||
type="search"
|
||||
placeholder="Search plants..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800 px-3.5 py-2.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-all"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-3 w-full rounded-lg bg-leaf-green-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
53
apps/web/src/components/PlantCard.tsx
Normal file
53
apps/web/src/components/PlantCard.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import type { Plant } from "@/data/plants";
|
||||
|
||||
interface PlantCardProps {
|
||||
plant: Plant;
|
||||
showDiseaseCount?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plant card showing emoji, name, family, and optional disease count.
|
||||
* Used on the homepage featured section and browse grid.
|
||||
*/
|
||||
export default function PlantCard({
|
||||
plant,
|
||||
showDiseaseCount = true,
|
||||
}: PlantCardProps) {
|
||||
const diseaseCount = plant.diseases.length;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/browse/${plant.id}`}
|
||||
className="group block rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm hover:shadow-md transition-all duration-200 hover:-translate-y-1 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
{/* Emoji illustration area */}
|
||||
<div className="flex items-center justify-center h-40 bg-gradient-to-br from-leaf-green-50 to-leaf-green-100 dark:from-leaf-green-950 dark:to-leaf-green-900">
|
||||
<span
|
||||
className="text-6xl transition-transform duration-300 group-hover:scale-110"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{plant.imageEmoji}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-100 group-hover:text-leaf-green-700 dark:group-hover:text-leaf-green-400 transition-colors">
|
||||
{plant.commonName}
|
||||
</h3>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 italic mt-0.5">
|
||||
{plant.family}
|
||||
</p>
|
||||
{showDiseaseCount && (
|
||||
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{diseaseCount === 0
|
||||
? "No known diseases in database"
|
||||
: `${diseaseCount} ${diseaseCount === 1 ? "disease" : "diseases"} tracked`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
212
apps/web/src/components/ResultsDashboard.tsx
Normal file
212
apps/web/src/components/ResultsDashboard.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import type { IdentifyResponse, PredictionResult } from "@/lib/types";
|
||||
import DiseaseCard from "@/components/DiseaseCard";
|
||||
import LoadingSkeleton, { ResultsSkeleton } from "@/components/LoadingSkeleton";
|
||||
import EmptyState from "@/components/EmptyState";
|
||||
import { getPlantById } from "@/lib/api/diseases";
|
||||
|
||||
/**
|
||||
* Top-level results layout: uploaded image preview + ranked prediction cards.
|
||||
*
|
||||
* Side-by-side on desktop (image left, results right), stacked on mobile.
|
||||
* Loading skeleton state while results are computed.
|
||||
* Error state if identification fails.
|
||||
* Empty/unexpected state.
|
||||
*/
|
||||
export default function ResultsDashboard({
|
||||
imageId,
|
||||
imageUrl,
|
||||
response,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
imageId: string;
|
||||
imageUrl: string;
|
||||
response: IdentifyResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
|
||||
const [sortBy, setSortBy] = useState<"confidence" | "name">("confidence");
|
||||
|
||||
// Filter and sort predictions
|
||||
const predictions = useMemo(() => {
|
||||
if (!response?.predictions) return [];
|
||||
|
||||
let filtered = response.predictions.filter(
|
||||
(p: PredictionResult) => !dismissedIds.has(p.diseaseId)
|
||||
);
|
||||
|
||||
if (sortBy === "name") {
|
||||
filtered = [...filtered].sort((a, b) =>
|
||||
a.disease.name.localeCompare(b.disease.name)
|
||||
);
|
||||
} else {
|
||||
// Default: sort by confidence descending
|
||||
filtered = [...filtered].sort(
|
||||
(a, b) => b.confidence.adjusted - a.confidence.adjusted
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [response, dismissedIds, sortBy]);
|
||||
|
||||
const dismissDisease = useCallback((diseaseId: string) => {
|
||||
setDismissedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(diseaseId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearDismissed = useCallback(() => {
|
||||
setDismissedIds(new Set());
|
||||
}, []);
|
||||
|
||||
// ─── Loading state ────────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||
<ResultsSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Error state ──────────────────────────────────────────────────────────
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
illustration="🍂"
|
||||
title="Identification Failed"
|
||||
description={error}
|
||||
actionLabel="Try again"
|
||||
actionHref="/"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Empty / no predictions ───────────────────────────────────────────────
|
||||
|
||||
if (!response || predictions.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
illustration="🔍"
|
||||
title={predictions.length === 0 && dismissedIds.size > 0 ? "All results dismissed" : "No Results Found"}
|
||||
description={
|
||||
predictions.length === 0 && dismissedIds.size > 0
|
||||
? "You've dismissed all predictions. Click below to restore them."
|
||||
: "We couldn't identify any diseases in this image. Try uploading a clearer photo of the affected area."
|
||||
}
|
||||
actionLabel={predictions.length === 0 && dismissedIds.size > 0 ? "Restore results" : "Upload another photo"}
|
||||
actionHref={predictions.length === 0 && dismissedIds.size > 0 ? "#" : "/"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Results ──────────────────────────────────────────────────────────────
|
||||
|
||||
const primaryPrediction = predictions[0];
|
||||
const primaryDisease = primaryPrediction?.disease;
|
||||
const plant = primaryDisease ? getPlantById(primaryDisease.plantId) : null;
|
||||
const demoMode = response?.demo_mode ?? false;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||
{/* Page header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-zinc-100">
|
||||
Identification Results
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Analyzed {response?.metadata?.inferenceTimeMs ?? 0}ms · Model: {response?.metadata?.model ?? "unknown"}
|
||||
{demoMode && (
|
||||
<span className="ml-2 inline-flex items-center rounded-full bg-warning-amber-100 dark:bg-warning-amber-900/50 px-2 py-0.5 text-xs font-medium text-warning-amber-700 dark:text-warning-amber-300">
|
||||
Demo mode
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main layout: image + results */}
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Left: Image preview */}
|
||||
<div className="w-full lg:w-80 lg:shrink-0">
|
||||
<div className="sticky top-8 space-y-4">
|
||||
<div className="rounded-xl overflow-hidden border border-zinc-200 dark:border-zinc-700 shadow-sm">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Uploaded plant image"
|
||||
className="w-full h-auto object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image metadata */}
|
||||
<div className="rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-4 py-3 space-y-1.5 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Image ID</span>
|
||||
<span className="font-mono text-zinc-700 dark:text-zinc-300 truncate ml-2 max-w-[120px]">
|
||||
{imageId.slice(0, 12)}…
|
||||
</span>
|
||||
</div>
|
||||
{plant && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Likely plant</span>
|
||||
<span className="text-zinc-700 dark:text-zinc-300">{plant.commonName}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Predictions</span>
|
||||
<span className="text-zinc-700 dark:text-zinc-300">
|
||||
{predictions.length} shown
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="sort-select" className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Sort by:
|
||||
</label>
|
||||
<select
|
||||
id="sort-select"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as "confidence" | "name")}
|
||||
className="rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-2 py-1 text-xs text-zinc-700 dark:text-zinc-300 focus:ring-2 focus:ring-leaf-green-500"
|
||||
>
|
||||
<option value="confidence">Confidence</option>
|
||||
<option value="name">Name (A-Z)</option>
|
||||
</select>
|
||||
|
||||
{dismissedIds.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearDismissed}
|
||||
className="ml-auto text-xs text-leaf-green-600 dark:text-leaf-green-400 hover:underline"
|
||||
>
|
||||
Restore all ({dismissedIds.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Ranked prediction cards */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{predictions.map((prediction, index) => (
|
||||
<DiseaseCard
|
||||
key={prediction.diseaseId}
|
||||
prediction={prediction}
|
||||
rank={index + 1}
|
||||
isPrimary={index === 0}
|
||||
onDismiss={() => dismissDisease(prediction.diseaseId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
apps/web/src/components/SymptomChecker.test.tsx
Normal file
159
apps/web/src/components/SymptomChecker.test.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import SymptomChecker from "@/components/SymptomChecker";
|
||||
|
||||
describe("SymptomChecker", () => {
|
||||
const symptoms = [
|
||||
"Dark brown spots with concentric rings on lower leaves",
|
||||
"Yellowing of leaves surrounding infected spots",
|
||||
"Premature defoliation starting from bottom of plant",
|
||||
"Dark sunken lesions on stems and fruit",
|
||||
"Wilting of severely affected branches",
|
||||
];
|
||||
|
||||
function renderChecker(customSymptoms?: string[]) {
|
||||
return render(<SymptomChecker symptoms={customSymptoms ?? symptoms} />);
|
||||
}
|
||||
|
||||
describe("initial state", () => {
|
||||
it("shows all symptoms as unchecked", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
expect(checkboxes).toHaveLength(5);
|
||||
checkboxes.forEach((cb) => expect(cb).not.toBeChecked());
|
||||
});
|
||||
|
||||
it("shows match counter as 0 of N", () => {
|
||||
renderChecker();
|
||||
expect(screen.getByText(/0 of 5 symptoms match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Weak match' label when no symptoms checked", () => {
|
||||
renderChecker();
|
||||
expect(screen.getByText(/Weak match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the symptom check title", () => {
|
||||
renderChecker();
|
||||
expect(screen.getByText("Symptom Check")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows guidance text when no symptoms are checked", () => {
|
||||
renderChecker();
|
||||
expect(screen.getByText(/Check symptoms you see on your plant/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("counter updates when toggling checkboxes", () => {
|
||||
it("increments counter when checking a symptom", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
fireEvent.click(checkboxes[0]);
|
||||
|
||||
expect(screen.getByText(/1 of 5 symptoms match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("decrements counter when unchecking a symptom", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
fireEvent.click(checkboxes[0]);
|
||||
fireEvent.click(checkboxes[0]);
|
||||
|
||||
expect(screen.getByText(/0 of 5 symptoms match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates correctly when checking multiple symptoms", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
fireEvent.click(checkboxes[0]);
|
||||
fireEvent.click(checkboxes[1]);
|
||||
fireEvent.click(checkboxes[2]);
|
||||
|
||||
expect(screen.getByText(/3 of 5 symptoms match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Partial match' at 50% threshold", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
fireEvent.click(checkboxes[0]);
|
||||
fireEvent.click(checkboxes[1]); // 2 of 5 = 40% — still weak
|
||||
fireEvent.click(checkboxes[2]); // 3 of 5 = 60% — partial
|
||||
|
||||
expect(screen.getByText(/Partial match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Strong match' at 80% threshold", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
// Check 4 of 5 = 80%
|
||||
fireEvent.click(checkboxes[0]);
|
||||
fireEvent.click(checkboxes[1]);
|
||||
fireEvent.click(checkboxes[2]);
|
||||
fireEvent.click(checkboxes[3]);
|
||||
|
||||
expect(screen.getByText(/Strong match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Strong match' at 100%", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
checkboxes.forEach((cb) => fireEvent.click(cb));
|
||||
|
||||
expect(screen.getByText(/5 of 5 symptoms match/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Strong match/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("visual feedback", () => {
|
||||
it("highlights checked symptom rows with green background", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
fireEvent.click(checkboxes[0]);
|
||||
|
||||
const checkedLabel = checkboxes[0].closest("label");
|
||||
expect(checkedLabel).toHaveClass("bg-leaf-green-50");
|
||||
});
|
||||
|
||||
it("shows guidance text only when no symptoms are checked", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
fireEvent.click(checkboxes[0]);
|
||||
|
||||
expect(screen.queryByText(/Check symptoms you see on your plant/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty symptoms array", () => {
|
||||
renderChecker([]);
|
||||
expect(screen.getByText(/0 of 0 symptoms match/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles single symptom", () => {
|
||||
renderChecker(["Single symptom"]);
|
||||
expect(screen.getByText(/0 of 1 symptoms match/)).toBeInTheDocument();
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
fireEvent.click(checkbox);
|
||||
expect(screen.getByText(/1 of 1 symptoms match/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("progress bar", () => {
|
||||
it("shows 0% width initially", () => {
|
||||
renderChecker();
|
||||
const progressBar = document.querySelector('[style*="width"]') as HTMLElement;
|
||||
expect(progressBar).toHaveStyle({ width: "0%" });
|
||||
});
|
||||
|
||||
it("updates width when symptoms are checked", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
fireEvent.click(checkboxes[0]); // 1 of 5 = 20%
|
||||
|
||||
const progressBar = document.querySelector('[style*="width"]') as HTMLElement;
|
||||
expect(progressBar).toHaveStyle({ width: "20%" });
|
||||
});
|
||||
});
|
||||
});
|
||||
124
apps/web/src/components/SymptomChecker.tsx
Normal file
124
apps/web/src/components/SymptomChecker.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Visual symptom checklist with severity indicators.
|
||||
*
|
||||
* For the predicted disease, shows a list of common symptoms with checkboxes.
|
||||
* User can check which symptoms they observe on their plant.
|
||||
* A match counter shows "3 of 5 symptoms match".
|
||||
* Helps user confirm/reject the diagnosis.
|
||||
*/
|
||||
export default function SymptomChecker({
|
||||
symptoms,
|
||||
}: {
|
||||
symptoms: string[];
|
||||
}) {
|
||||
const [checked, setChecked] = useState<Set<number>>(new Set());
|
||||
|
||||
const toggle = useCallback((index: number) => {
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const matched = checked.size;
|
||||
const total = symptoms.length;
|
||||
const matchRatio = total > 0 ? matched / total : 0;
|
||||
|
||||
// Severity indicator color based on match ratio
|
||||
const severityColor =
|
||||
matchRatio >= 0.8
|
||||
? "text-leaf-green-700 dark:text-leaf-green-400"
|
||||
: matchRatio >= 0.5
|
||||
? "text-warning-amber-700 dark:text-warning-amber-400"
|
||||
: "text-zinc-500 dark:text-zinc-400";
|
||||
|
||||
const severityLabel =
|
||||
matchRatio >= 0.8
|
||||
? "Strong match"
|
||||
: matchRatio >= 0.5
|
||||
? "Partial match"
|
||||
: "Weak match";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Match counter header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Symptom Check
|
||||
</h4>
|
||||
<span className={`text-sm font-medium ${severityColor}`}>
|
||||
{matched} of {total} symptoms match · {severityLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Match progress bar */}
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ease-out ${
|
||||
matchRatio >= 0.8
|
||||
? "bg-leaf-green-500"
|
||||
: matchRatio >= 0.5
|
||||
? "bg-warning-amber-500"
|
||||
: "bg-zinc-400 dark:bg-zinc-500"
|
||||
}`}
|
||||
style={{ width: `${matchRatio * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Symptom list */}
|
||||
<ul className="space-y-2" role="list">
|
||||
{symptoms.map((symptom, index) => {
|
||||
const isChecked = checked.has(index);
|
||||
return (
|
||||
<li key={index}>
|
||||
<label
|
||||
className={`
|
||||
flex items-start gap-3 rounded-lg px-3 py-2.5 cursor-pointer
|
||||
transition-colors duration-150
|
||||
${
|
||||
isChecked
|
||||
? "bg-leaf-green-50 dark:bg-leaf-green-950/30 border border-leaf-green-200 dark:border-leaf-green-800"
|
||||
: "bg-zinc-50 dark:bg-zinc-800/50 border border-transparent hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Custom checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggle(index)}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 rounded border-zinc-300 dark:border-zinc-600 text-leaf-green-600 focus:ring-leaf-green-500 bg-white dark:bg-zinc-800"
|
||||
/>
|
||||
<span
|
||||
className={`text-sm leading-relaxed ${
|
||||
isChecked
|
||||
? "text-zinc-900 dark:text-zinc-100"
|
||||
: "text-zinc-600 dark:text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{symptom}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* Guidance text */}
|
||||
{matched === 0 && (
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 italic">
|
||||
Check symptoms you see on your plant to verify the diagnosis.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
apps/web/src/components/TreatmentTimeline.test.tsx
Normal file
120
apps/web/src/components/TreatmentTimeline.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import TreatmentTimeline, { treatmentStepsWithUrgency, type TreatmentStep } from "@/components/TreatmentTimeline";
|
||||
|
||||
describe("TreatmentTimeline", () => {
|
||||
const mockSteps: TreatmentStep[] = [
|
||||
{ action: "Remove and destroy all severely infected leaves immediately", urgency: "immediate" },
|
||||
{ action: "Apply copper-based fungicide spray every 7-10 days", urgency: "within-week" },
|
||||
{ action: "Improve air circulation by pruning lower leaves", urgency: "ongoing" },
|
||||
{ action: "Mulch around base with 2-3 inches of straw", urgency: "ongoing" },
|
||||
{ action: "Switch to drip irrigation to keep foliage dry", urgency: "ongoing" },
|
||||
];
|
||||
|
||||
function renderTimeline(steps: TreatmentStep[]) {
|
||||
return render(<TreatmentTimeline steps={steps} />);
|
||||
}
|
||||
|
||||
describe("renders all treatment steps", () => {
|
||||
it("shows all step actions", () => {
|
||||
renderTimeline(mockSteps);
|
||||
mockSteps.forEach((step) => {
|
||||
expect(screen.getByText(step.action)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows numbered step indicators", () => {
|
||||
renderTimeline(mockSteps);
|
||||
for (let i = 1; i <= mockSteps.length; i++) {
|
||||
expect(screen.getByText(i.toString())).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows urgency badges for each step", () => {
|
||||
renderTimeline(mockSteps);
|
||||
expect(screen.getByText("Immediate")).toBeInTheDocument();
|
||||
expect(screen.getByText("Within a week")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Ongoing").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("urgency levels", () => {
|
||||
it("renders immediate urgency with red styling", () => {
|
||||
renderTimeline(mockSteps);
|
||||
const immediateBadge = screen.getByText("Immediate");
|
||||
expect(immediateBadge.closest("span")).toHaveClass("bg-red-100");
|
||||
});
|
||||
|
||||
it("renders within-week urgency with amber styling", () => {
|
||||
renderTimeline(mockSteps);
|
||||
const weekBadge = screen.getByText("Within a week");
|
||||
expect(weekBadge.closest("span")).toHaveClass("bg-warning-amber-100");
|
||||
});
|
||||
|
||||
it("renders ongoing urgency with green styling", () => {
|
||||
renderTimeline(mockSteps);
|
||||
const ongoingBadges = screen.getAllByText("Ongoing");
|
||||
expect(ongoingBadges.length).toBeGreaterThan(0);
|
||||
expect(ongoingBadges[0].closest("span")).toHaveClass("bg-leaf-green-100");
|
||||
});
|
||||
});
|
||||
|
||||
describe("disclaimer", () => {
|
||||
it("shows treatment disclaimer at bottom", () => {
|
||||
renderTimeline(mockSteps);
|
||||
expect(screen.getByText(/Treatments may vary depending on plant species/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state", () => {
|
||||
it("shows message when no steps provided", () => {
|
||||
renderTimeline([]);
|
||||
expect(screen.getByText("No treatment steps available.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeline connectors", () => {
|
||||
it("shows connector lines between steps", () => {
|
||||
renderTimeline(mockSteps);
|
||||
// There should be N-1 connector lines
|
||||
const connectors = document.querySelectorAll('.bg-zinc-200.dark\\:bg-zinc-700');
|
||||
// At least some connectors should exist for multi-step timelines
|
||||
expect(connectors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("treatmentStepsWithUrgency helper", () => {
|
||||
it("maps first step to immediate", () => {
|
||||
const steps = treatmentStepsWithUrgency(["Step 1", "Step 2", "Step 3"]);
|
||||
expect(steps[0].urgency).toBe("immediate");
|
||||
});
|
||||
|
||||
it("maps second step to within-week", () => {
|
||||
const steps = treatmentStepsWithUrgency(["Step 1", "Step 2", "Step 3"]);
|
||||
expect(steps[1].urgency).toBe("within-week");
|
||||
});
|
||||
|
||||
it("maps remaining steps to ongoing", () => {
|
||||
const steps = treatmentStepsWithUrgency(["Step 1", "Step 2", "Step 3", "Step 4"]);
|
||||
expect(steps[2].urgency).toBe("ongoing");
|
||||
expect(steps[3].urgency).toBe("ongoing");
|
||||
});
|
||||
|
||||
it("preserves action text", () => {
|
||||
const actions = ["Remove leaves", "Apply fungicide", "Improve circulation"];
|
||||
const steps = treatmentStepsWithUrgency(actions);
|
||||
expect(steps.map((s) => s.action)).toEqual(actions);
|
||||
});
|
||||
|
||||
it("handles single step", () => {
|
||||
const steps = treatmentStepsWithUrgency(["Only step"]);
|
||||
expect(steps).toHaveLength(1);
|
||||
expect(steps[0].urgency).toBe("immediate");
|
||||
});
|
||||
|
||||
it("handles empty array", () => {
|
||||
const steps = treatmentStepsWithUrgency([]);
|
||||
expect(steps).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
155
apps/web/src/components/TreatmentTimeline.tsx
Normal file
155
apps/web/src/components/TreatmentTimeline.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { Severity } from "@/lib/types";
|
||||
|
||||
/** Urgency level for treatment steps */
|
||||
export type UrgencyLevel = "immediate" | "within-week" | "ongoing";
|
||||
|
||||
/** A single treatment step with urgency metadata */
|
||||
export interface TreatmentStep {
|
||||
action: string;
|
||||
urgency: UrgencyLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered treatment steps displayed as a timeline.
|
||||
*
|
||||
* Each step has:
|
||||
* - Action text
|
||||
* - Urgency badge (immediate / within week / ongoing)
|
||||
* - Timeline connector
|
||||
*
|
||||
* Shows "Treatments may vary" disclaimer at bottom.
|
||||
*/
|
||||
export default function TreatmentTimeline({
|
||||
steps,
|
||||
severity,
|
||||
}: {
|
||||
steps: TreatmentStep[];
|
||||
severity?: Severity;
|
||||
}) {
|
||||
if (steps.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 italic">
|
||||
No treatment steps available.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{/* Timeline container */}
|
||||
<ol className="relative space-y-0" role="list">
|
||||
{steps.map((step, index) => {
|
||||
const isLast = index === steps.length - 1;
|
||||
const urgencyConfig = getUrgencyConfig(step.urgency);
|
||||
|
||||
return (
|
||||
<li key={index} className="relative flex gap-4">
|
||||
{/* Timeline line and node */}
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Node circle */}
|
||||
<div
|
||||
className={`
|
||||
flex h-8 w-8 shrink-0 items-center justify-center rounded-full
|
||||
${urgencyConfig.bg} ${urgencyConfig.ring} ring-2
|
||||
`}
|
||||
>
|
||||
<span className={`text-sm font-bold ${urgencyConfig.text}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connector line (hidden for last item) */}
|
||||
{!isLast && (
|
||||
<div className="w-0.5 grow bg-zinc-200 dark:bg-zinc-700 my-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className={`pb-6 ${isLast ? "pb-0" : ""}`}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${urgencyConfig.badge}`}>
|
||||
{urgencyConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">
|
||||
{step.action}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="mt-4 flex items-start gap-2 rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-3 py-2.5">
|
||||
<svg className="mt-0.5 h-4 w-4 shrink-0 text-zinc-400 dark:text-zinc-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed">
|
||||
Treatments may vary depending on plant species, severity, and local conditions.
|
||||
Always consult a certified plant pathologist or extension service for critical decisions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a flat treatment array into TreatmentStep objects with urgency levels.
|
||||
*
|
||||
* Maps: first step → immediate, second step → within-week, rest → ongoing.
|
||||
*/
|
||||
export function treatmentStepsWithUrgency(treatment: string[]): TreatmentStep[] {
|
||||
return treatment.map((action, index) => ({
|
||||
action,
|
||||
urgency: getUrgencyForIndex(index),
|
||||
}));
|
||||
}
|
||||
|
||||
function getUrgencyForIndex(index: number): UrgencyLevel {
|
||||
if (index === 0) return "immediate";
|
||||
if (index === 1) return "within-week";
|
||||
return "ongoing";
|
||||
}
|
||||
|
||||
// ─── Urgency styling ─────────────────────────────────────────────────────────
|
||||
|
||||
interface UrgencyConfig {
|
||||
label: string;
|
||||
bg: string;
|
||||
text: string;
|
||||
ring: string;
|
||||
badge: string;
|
||||
}
|
||||
|
||||
function getUrgencyConfig(urgency: UrgencyLevel): UrgencyConfig {
|
||||
switch (urgency) {
|
||||
case "immediate":
|
||||
return {
|
||||
label: "Immediate",
|
||||
bg: "bg-red-100 dark:bg-red-900/50",
|
||||
text: "text-red-700 dark:text-red-300",
|
||||
ring: "ring-red-300 dark:ring-red-700",
|
||||
badge: "bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300",
|
||||
};
|
||||
case "within-week":
|
||||
return {
|
||||
label: "Within a week",
|
||||
bg: "bg-warning-amber-100 dark:bg-warning-amber-900/50",
|
||||
text: "text-warning-amber-700 dark:text-warning-amber-300",
|
||||
ring: "ring-warning-amber-300 dark:ring-warning-amber-700",
|
||||
badge: "bg-warning-amber-100 dark:bg-warning-amber-900/50 text-warning-amber-700 dark:text-warning-amber-300",
|
||||
};
|
||||
case "ongoing":
|
||||
return {
|
||||
label: "Ongoing",
|
||||
bg: "bg-leaf-green-100 dark:bg-leaf-green-900/50",
|
||||
text: "text-leaf-green-700 dark:text-leaf-green-300",
|
||||
ring: "ring-leaf-green-300 dark:ring-leaf-green-700",
|
||||
badge: "bg-leaf-green-100 dark:bg-leaf-green-900/50 text-leaf-green-700 dark:text-leaf-green-300",
|
||||
};
|
||||
}
|
||||
}
|
||||
0
apps/web/src/data/.gitkeep
Normal file
0
apps/web/src/data/.gitkeep
Normal file
3524
apps/web/src/data/diseases.json
Normal file
3524
apps/web/src/data/diseases.json
Normal file
File diff suppressed because it is too large
Load Diff
263
apps/web/src/data/plants.json
Normal file
263
apps/web/src/data/plants.json
Normal file
@@ -0,0 +1,263 @@
|
||||
[
|
||||
{
|
||||
"id": "tomato",
|
||||
"commonName": "Tomato",
|
||||
"scientificName": "Solanum lycopersicum",
|
||||
"family": "Solanaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), consistent watering (1-2 inches/week), well-drained soil pH 6.0-6.8, regular feeding with balanced fertilizer, support with stakes or cages.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Solanum_lycopersicum_-_Tomato.jpg/320px-Solanum_lycopersicum_-_Tomato.jpg"
|
||||
},
|
||||
{
|
||||
"id": "basil",
|
||||
"commonName": "Basil",
|
||||
"scientificName": "Ocimum basilicum",
|
||||
"family": "Lamiaceae",
|
||||
"category": "herb",
|
||||
"careSummary": "Full sun (6-8h), moderate watering (keep soil moist but not soggy), warm temperatures (70-90°F), pinching flowers encourages bushier growth.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/95/Basil.jpg/320px-Basil.jpg"
|
||||
},
|
||||
{
|
||||
"id": "rose",
|
||||
"commonName": "Rose",
|
||||
"scientificName": "Rosa spp.",
|
||||
"family": "Rosaceae",
|
||||
"category": "flower",
|
||||
"careSummary": "Full sun (6h+), deep watering 2-3 times weekly, well-drained slightly acidic soil, regular deadheading, annual pruning in late winter.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4c/Rosa_rubiginosa_002.JPG/320px-Rosa_rubiginosa_002.JPG"
|
||||
},
|
||||
{
|
||||
"id": "monstera",
|
||||
"commonName": "Monstera",
|
||||
"scientificName": "Monstera deliciosa",
|
||||
"family": "Araceae",
|
||||
"category": "houseplant",
|
||||
"careSummary": "Bright indirect light, water when top 2-3 inches of soil are dry, humidity 60-80%, temperatures 65-85°F, well-draining aroid mix.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dc/Monstera_deliciosa_leaf.jpg/320px-Monstera_deliciosa_leaf.jpg"
|
||||
},
|
||||
{
|
||||
"id": "pothos",
|
||||
"commonName": "Pothos",
|
||||
"scientificName": "Epipremnum aureum",
|
||||
"family": "Araceae",
|
||||
"category": "houseplant",
|
||||
"careSummary": "Low to bright indirect light, water when top inch of soil is dry, tolerates low humidity, temperatures 60-85°F, very forgiving and low-maintenance.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/Epipremnum_aureum_2.jpg/320px-Epipremnum_aureum_2.jpg"
|
||||
},
|
||||
{
|
||||
"id": "snake-plant",
|
||||
"commonName": "Snake Plant",
|
||||
"scientificName": "Dracaena trifasciata",
|
||||
"family": "Asparagaceae",
|
||||
"category": "houseplant",
|
||||
"careSummary": "Tolerates low to bright indirect light, water sparingly every 2-3 weeks, drought tolerant, temperatures 55-85°F, well-draining cactus mix.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/33/Sansevieria_trifasciata_Laurentii.jpg/320px-Sansevieria_trifasciata_Laurentii.jpg"
|
||||
},
|
||||
{
|
||||
"id": "peace-lily",
|
||||
"commonName": "Peace Lily",
|
||||
"scientificName": "Spathiphyllum wallisii",
|
||||
"family": "Araceae",
|
||||
"category": "houseplant",
|
||||
"careSummary": "Low to medium indirect light, keep soil consistently moist but not waterlogged, high humidity preferred, temperatures 65-80°F, sensitive to fluoride in water.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/07/Spathiphyllum_wallisii_1.jpg/320px-Spathiphyllum_wallisii_1.jpg"
|
||||
},
|
||||
{
|
||||
"id": "orchid",
|
||||
"commonName": "Phalaenopsis Orchid",
|
||||
"scientificName": "Phalaenopsis amabilis",
|
||||
"family": "Orchidaceae",
|
||||
"category": "houseplant",
|
||||
"careSummary": "Bright indirect light, water weekly by soaking roots for 15 minutes then draining completely, humidity 50-70%, temperatures 65-80°F, bark-based orchid mix.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Phalaenopsis_amabilis_01.JPG/320px-Phalaenopsis_amabilis_01.JPG"
|
||||
},
|
||||
{
|
||||
"id": "succulent",
|
||||
"commonName": "Succulent (Echeveria)",
|
||||
"scientificName": "Echeveria elegans",
|
||||
"family": "Crassulaceae",
|
||||
"category": "succulent",
|
||||
"careSummary": "Bright direct light (6h+), water only when soil is completely dry (soak and dry method), excellent drainage essential, temperatures 60-80°F, sandy well-draining mix.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/Echeveria_Elegans_01.jpg/320px-Echeveria_Elegans_01.jpg"
|
||||
},
|
||||
{
|
||||
"id": "pepper",
|
||||
"commonName": "Bell Pepper",
|
||||
"scientificName": "Capsicum annuum",
|
||||
"family": "Solanaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), consistent watering, warm soil (70-80°F), well-drained fertile soil pH 6.0-6.8, regular feeding with high-potassium fertilizer during fruiting.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Capsicum_annuum_%27California_Wonder%27.jpg/320px-Capsicum_annuum_%27California_Wonder%27.jpg"
|
||||
},
|
||||
{
|
||||
"id": "cucumber",
|
||||
"commonName": "Cucumber",
|
||||
"scientificName": "Cucumis sativus",
|
||||
"family": "Cucurbitaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), consistent deep watering (1-2 inches/week), warm temperatures (70-95°F), trellis support recommended, mulch to retain moisture.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/96/Cucumis_sativus_002.jpg/320px-Cucumis_sativus_002.jpg"
|
||||
},
|
||||
{
|
||||
"id": "squash",
|
||||
"commonName": "Summer Squash",
|
||||
"scientificName": "Cucurbita pepo",
|
||||
"family": "Cucurbitaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), deep watering (1-2 inches/week), warm temperatures (65-80°F), well-drained fertile soil, space plants 2-3 feet apart.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Cucurbita_pepo_002.jpg/320px-Cucurbita_pepo_002.jpg"
|
||||
},
|
||||
{
|
||||
"id": "bean",
|
||||
"commonName": "Green Bean",
|
||||
"scientificName": "Phaseolus vulgaris",
|
||||
"family": "Fabaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), moderate watering (keep soil evenly moist), warm temperatures (65-80°F), trellis for pole varieties, benefits from nitrogen-fixing roots.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f0/Phaseolus_vulgaris_003.jpg/320px-Phaseolus_vulgaris_003.jpg"
|
||||
},
|
||||
{
|
||||
"id": "strawberry",
|
||||
"commonName": "Strawberry",
|
||||
"scientificName": "Fragaria × ananassa",
|
||||
"family": "Rosaceae",
|
||||
"category": "fruit",
|
||||
"careSummary": "Full sun (6-8h), consistent watering (1-2 inches/week), well-drained slightly acidic soil pH 5.5-6.5, mulch with straw to protect fruit, remove runners for larger berries.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1a/Fragaria_x_ananassa_002.jpg/320px-Fragaria_x_ananassa_002.jpg"
|
||||
},
|
||||
{
|
||||
"id": "mint",
|
||||
"commonName": "Mint",
|
||||
"scientificName": "Mentha spp.",
|
||||
"family": "Lamiaceae",
|
||||
"category": "herb",
|
||||
"careSummary": "Partial shade to full sun, keep soil consistently moist, cool to warm temperatures (60-70°F), container growing recommended to prevent spreading, regular harvesting encourages growth.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/36/Mentha_spicata_002.jpg/320px-Mentha_spicata_002.jpg"
|
||||
},
|
||||
{
|
||||
"id": "lavender",
|
||||
"commonName": "Lavender",
|
||||
"scientificName": "Lavandula angustifolia",
|
||||
"family": "Lamiaceae",
|
||||
"category": "herb",
|
||||
"careSummary": "Full sun (6-8h+), drought tolerant once established, well-drained alkaline soil pH 6.5-7.5, prune after flowering, temperatures 50-75°F.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/05/Lavandula_angustifolia_002.jpg/320px-Lavandula_angustifolia_002.jpg"
|
||||
},
|
||||
{
|
||||
"id": "lettuce",
|
||||
"commonName": "Lettuce",
|
||||
"scientificName": "Lactuca sativa",
|
||||
"family": "Asteraceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Partial shade to full sun, consistent moisture (shallow watering), cool temperatures (55-75°F), well-drained fertile soil, succession planting every 2 weeks.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/23/Lactuca_sativa_002.jpg/320px-Lactuca_sativa_002.jpg"
|
||||
},
|
||||
{
|
||||
"id": "cabbage",
|
||||
"commonName": "Cabbage",
|
||||
"scientificName": "Brassica oleracea var. capitata",
|
||||
"family": "Brassicaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), consistent deep watering, cool to moderate temperatures (50-85°F), rich well-drained soil, side-dress with nitrogen mid-season.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/11/Bok_choy.jpg/320px-Bok_choy.jpg"
|
||||
},
|
||||
{
|
||||
"id": "sunflower",
|
||||
"commonName": "Sunflower",
|
||||
"scientificName": "Helianthus annuus",
|
||||
"family": "Asteraceae",
|
||||
"category": "flower",
|
||||
"careSummary": "Full sun (6-8h+), moderate watering (deep but infrequent), warm temperatures (70-78°F), well-drained soil, tall varieties need staking in wind.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/Helianthus_annuus_in_Jena.jpg/320px-Helianthus_annuus_in_Jena.jpg"
|
||||
},
|
||||
{
|
||||
"id": "fiddle-leaf-fig",
|
||||
"commonName": "Fiddle Leaf Fig",
|
||||
"scientificName": "Ficus lyrata",
|
||||
"family": "Moraceae",
|
||||
"category": "houseplant",
|
||||
"careSummary": "Bright indirect light (no direct harsh sun), water when top 1-2 inches of soil are dry, humidity 40-60%, temperatures 60-75°F, avoid moving once placed.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f0/Ficus_lyrata_002.jpg/320px-Ficus_lyrata_002.jpg"
|
||||
},
|
||||
{
|
||||
"id": "aloe-vera",
|
||||
"commonName": "Aloe Vera",
|
||||
"scientificName": "Aloe barbadensis miller",
|
||||
"family": "Asphodelaceae",
|
||||
"category": "succulent",
|
||||
"careSummary": "Bright indirect to direct light, water deeply every 2-3 weeks, allow soil to dry completely between waterings, temperatures 55-80°F, well-draining cactus mix.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/Aloe_vera_leaf_cutaway.jpg/320px-Aloe_vera_leaf_cutaway.jpg"
|
||||
},
|
||||
{
|
||||
"id": "jasmine",
|
||||
"commonName": "Jasmine",
|
||||
"scientificName": "Jasminum officinale",
|
||||
"family": "Oleaceae",
|
||||
"category": "flower",
|
||||
"careSummary": "Full sun to partial shade (6h+), regular watering (keep soil moist), warm temperatures (60-75°F), trellis support for climbing varieties, prune after flowering.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/63/Jasminum_officinale_002.jpg/320px-Jasminum_officinale_002.jpg"
|
||||
},
|
||||
{
|
||||
"id": "chili",
|
||||
"commonName": "Chili Pepper",
|
||||
"scientificName": "Capsicum chinense",
|
||||
"family": "Solanaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (8h+), consistent watering (not waterlogged), warm temperatures (70-85°F), well-drained fertile soil, high-potassium fertilizer during fruiting.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/Capsicum_chinense_%27Habanero%27.jpg/320px-Capsicum_chinense_%27Habanero%27.jpg"
|
||||
},
|
||||
{
|
||||
"id": "eggplant",
|
||||
"commonName": "Eggplant",
|
||||
"scientificName": "Solanum melongena",
|
||||
"family": "Solanaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), consistent deep watering, warm temperatures (70-85°F), well-drained fertile soil, mulch to retain moisture, stake or cage for support.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/94/Eggplant_01.jpg/320px-Eggplant_01.jpg"
|
||||
},
|
||||
{
|
||||
"id": "spinach",
|
||||
"commonName": "Spinach",
|
||||
"scientificName": "Spinacia oleracea",
|
||||
"family": "Amaranthaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Partial shade to full sun, consistent moisture, cool temperatures (50-70°F), well-drained fertile soil, bolt quickly in heat — plant in spring or fall.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Spinach_leaves.jpg/320px-Spinach_leaves.jpg"
|
||||
},
|
||||
{
|
||||
"id": "fern",
|
||||
"commonName": "Boston Fern",
|
||||
"scientificName": "Nephrolepis exaltata",
|
||||
"family": "Nephrolepidaceae",
|
||||
"category": "houseplant",
|
||||
"careSummary": "Bright indirect light, keep soil consistently moist (never dry out), high humidity 50-80%, temperatures 60-75°F, regular misting or humidity tray recommended.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/42/Nephrolepis_exaltata_002.jpg/320px-Nephrolepis_exaltata_002.jpg"
|
||||
},
|
||||
{
|
||||
"id": "daisy",
|
||||
"commonName": "Shasta Daisy",
|
||||
"scientificName": "Leucanthemum × superbum",
|
||||
"family": "Asteraceae",
|
||||
"category": "flower",
|
||||
"careSummary": "Full sun (6h+), moderate watering, cool to moderate temperatures (60-75°F), well-drained soil, deadhead spent blooms, divide clumps every 3-4 years.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Leucanthemum_superbum_002.jpg/320px-Leucanthemum_superbum_002.jpg"
|
||||
},
|
||||
{
|
||||
"id": "zucchini",
|
||||
"commonName": "Zucchini",
|
||||
"scientificName": "Cucurbita pepo var. cylindrica",
|
||||
"family": "Cucurbitaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), deep consistent watering (1-2 inches/week), warm temperatures (65-80°F), well-drained fertile soil, harvest when 6-8 inches for best flavor.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Cucurbita_pepo_002.jpg/320px-Cucurbita_pepo_002.jpg"
|
||||
},
|
||||
{
|
||||
"id": "cactus",
|
||||
"commonName": "Cactus (Prickly Pear)",
|
||||
"scientificName": "Opuntia ficus-indica",
|
||||
"family": "Cactaceae",
|
||||
"category": "succulent",
|
||||
"careSummary": "Full sun (8h+), water sparingly (every 2-4 weeks in growing season, almost none in winter), extremely well-draining soil, temperatures 55-100°F, excellent heat/drought tolerance.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/Opuntia_ficus-indica_001.jpg/320px-Opuntia_ficus-indica_001.jpg"
|
||||
}
|
||||
]
|
||||
727
apps/web/src/data/plants.ts
Normal file
727
apps/web/src/data/plants.ts
Normal file
@@ -0,0 +1,727 @@
|
||||
/**
|
||||
* Static plant and disease knowledge base.
|
||||
*
|
||||
* In production this data could be served from an API, database, or CMS.
|
||||
* For now it provides the content powering the browse, detail, and homepage views.
|
||||
*/
|
||||
|
||||
export interface Disease {
|
||||
id: string;
|
||||
name: string;
|
||||
scientificName?: string;
|
||||
type: "fungal" | "bacterial" | "viral" | "pest" | "physiological";
|
||||
description: string;
|
||||
symptoms: string[];
|
||||
causes: string[];
|
||||
treatmentSteps: string[];
|
||||
preventionTips: string[];
|
||||
severity: "low" | "moderate" | "high" | "critical";
|
||||
commonName?: string;
|
||||
}
|
||||
|
||||
export interface Plant {
|
||||
id: string;
|
||||
commonName: string;
|
||||
scientificName: string;
|
||||
family: string;
|
||||
category: "vegetables" | "herbs" | "houseplants" | "flowers";
|
||||
description: string;
|
||||
careSummary: string;
|
||||
imageEmoji: string;
|
||||
diseases: Disease[];
|
||||
}
|
||||
|
||||
export const plants: Plant[] = [
|
||||
{
|
||||
id: "tomato",
|
||||
commonName: "Tomato",
|
||||
scientificName: "Solanum lycopersicum",
|
||||
family: "Solanaceae",
|
||||
category: "vegetables",
|
||||
description:
|
||||
"One of the most popular garden vegetables, tomatoes are grown worldwide. They are susceptible to several diseases that affect leaves, stems, and fruit.",
|
||||
careSummary: "Full sun, well-drained soil, regular watering at base. Stake or cage for support.",
|
||||
imageEmoji: "🍅",
|
||||
diseases: [
|
||||
{
|
||||
id: "tomato-late-blight",
|
||||
name: "Late Blight",
|
||||
scientificName: "Phytophthora infestans",
|
||||
type: "fungal",
|
||||
commonName: "Late blight",
|
||||
description:
|
||||
"A devastating water mold disease that caused the Irish Potato Famine. It spreads rapidly in cool, wet weather and can destroy a crop within days.",
|
||||
symptoms: [
|
||||
"Water-soaked, dark green to brown lesions on leaves",
|
||||
"White fuzzy mold on leaf undersides in humid conditions",
|
||||
"Dark, firm, greasy-looking spots on stems",
|
||||
"Brown, firm, irregular spots on green fruit that enlarge rapidly",
|
||||
],
|
||||
causes: [
|
||||
"Phytophthora infestans spores spread by wind and rain",
|
||||
"Cool temperatures (60-70°F / 15-21°C) with high humidity",
|
||||
"Overhead watering that keeps foliage wet for extended periods",
|
||||
"Infected volunteer plants or potato cull piles nearby",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy infected plant material immediately (do not compost)",
|
||||
"Apply copper-based fungicide or chlorothalonil at first sign",
|
||||
"Improve airflow by spacing plants and pruning lower branches",
|
||||
"Switch to drip irrigation to keep foliage dry",
|
||||
"Apply organic biofungicides containing Bacillus subtilis as preventive measure",
|
||||
],
|
||||
preventionTips: [
|
||||
"Plant resistant varieties (e.g., 'Mountain Magic', 'Defiant')",
|
||||
"Use mulch to prevent soil splash onto lower leaves",
|
||||
"Space plants adequately for air circulation",
|
||||
"Avoid overhead watering; water at soil level in the morning",
|
||||
"Rotate crops — avoid planting tomatoes in same spot for 3-4 years",
|
||||
],
|
||||
severity: "critical",
|
||||
},
|
||||
{
|
||||
id: "tomato-early-blight",
|
||||
name: "Early Blight",
|
||||
scientificName: "Alternaria solani",
|
||||
type: "fungal",
|
||||
description:
|
||||
"A common fungal disease that appears as distinctive target-like spots on older leaves first, gradually moving upward.",
|
||||
symptoms: [
|
||||
"Dark brown spots with concentric rings (target-like pattern) on lower leaves",
|
||||
"Yellowing of tissue surrounding leaf spots",
|
||||
"Leaf drop starting from the bottom of the plant",
|
||||
"Dark, sunken lesions on stems and fruit near the stem end",
|
||||
],
|
||||
causes: [
|
||||
"Alternaria solani fungus survives in soil and plant debris",
|
||||
"Warm, humid weather (75-85°F / 24-29°C) with frequent rain or dew",
|
||||
"Poor air circulation and dense foliage",
|
||||
"Overhead irrigation that wets leaves",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Prune lower branches and any infected leaves promptly",
|
||||
"Apply copper fungicide or sulfur-based fungicide every 7-14 days",
|
||||
"Remove and dispose of fallen infected leaves",
|
||||
"Apply a layer of organic mulch to prevent soil splash",
|
||||
"Use calcium spray to strengthen plant cell walls",
|
||||
],
|
||||
preventionTips: [
|
||||
"Choose early-blight-resistant varieties when available",
|
||||
"Rotate crops with non-solanaceous plants (beans, corn, lettuce)",
|
||||
"Space plants for good airflow (24-36 inches apart)",
|
||||
"Water at soil level in the morning using drip irrigation",
|
||||
"Remove plant debris at end of season",
|
||||
],
|
||||
severity: "moderate",
|
||||
},
|
||||
{
|
||||
id: "tomato-blossom-end-rot",
|
||||
name: "Blossom End Rot",
|
||||
type: "physiological",
|
||||
description:
|
||||
"A common physiological disorder caused by calcium deficiency in the fruit, often due to inconsistent watering — not a disease or pest.",
|
||||
symptoms: [
|
||||
"Water-soaked spot at the blossom end (bottom) of the fruit",
|
||||
"Spot enlarges and turns dark brown to black, leathery",
|
||||
"Fruit ripens prematurely on the affected area",
|
||||
"Affected area may become sunken and flat",
|
||||
],
|
||||
causes: [
|
||||
"Calcium deficiency in developing fruit due to inconsistent watering",
|
||||
"Extreme fluctuations in soil moisture (drought followed by heavy water)",
|
||||
"Over-fertilization with nitrogen, which interferes with calcium uptake",
|
||||
"Root damage or compacted soil limiting nutrient absorption",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Maintain consistent soil moisture — water regularly and deeply",
|
||||
"Remove affected fruit so the plant directs energy to healthy fruit",
|
||||
"Apply calcium spray (calcium chloride or calcium nitrate) to foliage",
|
||||
"Mulch around plants to retain soil moisture",
|
||||
"Test soil pH — aim for 6.5-7.0 for optimal calcium availability",
|
||||
],
|
||||
preventionTips: [
|
||||
"Water consistently — 1-2 inches per week, never let soil dry out completely",
|
||||
"Avoid excessive nitrogen fertilizers (use balanced 5-10-10 or similar)",
|
||||
"Incorporate lime or gypsum into soil before planting if calcium is low",
|
||||
"Plant in well-drained soil with good organic matter content",
|
||||
"Use drip irrigation with timer for consistent moisture",
|
||||
],
|
||||
severity: "moderate",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "basil",
|
||||
commonName: "Basil",
|
||||
scientificName: "Ocimum basilicum",
|
||||
family: "Lamiaceae",
|
||||
category: "herbs",
|
||||
description:
|
||||
"A fragrant culinary herb beloved in kitchens worldwide. Basil is relatively easy to grow but can be affected by several fungal diseases in humid conditions.",
|
||||
careSummary: "Full sun (6+ hours), well-drained soil, regular pruning to encourage bushy growth. Protect from frost.",
|
||||
imageEmoji: "🌿",
|
||||
diseases: [
|
||||
{
|
||||
id: "basil-downy-mildew",
|
||||
name: "Downy Mildew",
|
||||
scientificName: "Peronospora belbahrii",
|
||||
type: "fungal",
|
||||
description:
|
||||
"A devastating disease that has become the most serious threat to basil production worldwide. It spreads quickly and can wipe out a crop within days.",
|
||||
symptoms: [
|
||||
"Pale green to yellow angular patches between leaf veins",
|
||||
"Fuzzy gray-purple sporulation on leaf undersides",
|
||||
"Leaves curl and distort as disease progresses",
|
||||
"Leaves eventually turn brown and drop off",
|
||||
"Disease progresses from lower leaves upward",
|
||||
],
|
||||
causes: [
|
||||
"Peronospora belbahrii spores carried by wind and splashing water",
|
||||
"High humidity (>85%) and moderate temperatures (60-80°F)",
|
||||
"Overhead watering that keeps foliage wet overnight",
|
||||
"Overcrowding reducing air circulation around plants",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy infected plants immediately (do not compost)",
|
||||
"Apply copper-based fungicide as a protective measure",
|
||||
"Improve air circulation by increasing plant spacing",
|
||||
"Switch to drip irrigation to avoid wetting leaves",
|
||||
"Apply potassium phosphite fungicide if available",
|
||||
],
|
||||
preventionTips: [
|
||||
"Choose resistant varieties (e.g., 'Prospera', 'Rutgers Obsession')",
|
||||
"Space plants 8-12 inches apart for good airflow",
|
||||
"Water at soil level in the morning",
|
||||
"Avoid high-nitrogen fertilizers that promote lush, susceptible growth",
|
||||
"Use preventative fungicide applications during high-risk periods",
|
||||
],
|
||||
severity: "critical",
|
||||
},
|
||||
{
|
||||
id: "basil-fusarium-wilt",
|
||||
name: "Fusarium Wilt",
|
||||
scientificName: "Fusarium oxysporum f. sp. basilici",
|
||||
type: "fungal",
|
||||
description:
|
||||
"A soil-borne fungal disease that causes sudden wilting and death of basil plants. The fungus enters through the roots and blocks water-conducting tissue.",
|
||||
symptoms: [
|
||||
"Sudden wilting of leaves and shoots, often on one side first",
|
||||
"Stunted growth and yellowing of lower leaves",
|
||||
"Brown vascular discoloration visible when cutting open the stem",
|
||||
"Dark brown streaks on stems",
|
||||
"Plant eventually collapses and dies",
|
||||
],
|
||||
causes: [
|
||||
"Fusarium fungus survives in soil for years",
|
||||
"Contaminated seeds or transplants",
|
||||
"Warm soil temperatures (75-85°F / 24-29°C)",
|
||||
"Overwatering and poor soil drainage",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy infected plants and surrounding soil",
|
||||
"Do not plant basil in same location for 3-5 years",
|
||||
"Solarize soil (cover with clear plastic for 4-6 weeks in hot weather)",
|
||||
"Use raised beds with fresh potting mix for new plantings",
|
||||
"Apply beneficial Trichoderma fungi to soil as biological control",
|
||||
],
|
||||
preventionTips: [
|
||||
"Use disease-free seeds and transplants from reputable sources",
|
||||
"Plant resistant varieties (some sweet basil cultivars have tolerance)",
|
||||
"Rotate crops — avoid planting basil in same spot for 3+ years",
|
||||
"Improve drainage with raised beds or organic matter",
|
||||
"Sterilize pots and tools with 10% bleach solution",
|
||||
],
|
||||
severity: "high",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "rose",
|
||||
commonName: "Rose",
|
||||
scientificName: "Rosa spp.",
|
||||
family: "Rosaceae",
|
||||
category: "flowers",
|
||||
description:
|
||||
"The classic garden flower prized for its beauty and fragrance. Roses are heavy feeders and can be susceptible to several common diseases, especially in humid climates.",
|
||||
careSummary: "Full sun (6+ hours), rich well-drained soil, regular fertilizing, prune in early spring.",
|
||||
imageEmoji: "🌹",
|
||||
diseases: [
|
||||
{
|
||||
id: "rose-black-spot",
|
||||
name: "Black Spot",
|
||||
scientificName: "Diplocarpon rosae",
|
||||
type: "fungal",
|
||||
description:
|
||||
"The most common and damaging disease of roses worldwide. It causes unsightly black spots on leaves and can lead to complete defoliation.",
|
||||
symptoms: [
|
||||
"Circular black spots with fringed or feathery margins on leaves",
|
||||
"Yellowing of leaf tissue surrounding spots",
|
||||
"Premature leaf drop — plant may become nearly leafless",
|
||||
"Small red or purple spots on young canes",
|
||||
],
|
||||
causes: [
|
||||
"Fungus survives on infected fallen leaves and canes",
|
||||
"Warm, wet weather (70-80°F) with extended leaf wetness (7+ hours)",
|
||||
"Overhead watering that keeps foliage wet",
|
||||
"Overcrowded planting with poor air circulation",
|
||||
"Susceptible rose varieties",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy all infected leaves (both on plant and fallen)",
|
||||
"Apply sulfur or neem oil fungicide every 7-14 days during growing season",
|
||||
"Prune to improve air circulation — remove crossing branches",
|
||||
"Mulch around base to prevent soil splash",
|
||||
"Apply fungicide containing chlorothalonil or myclobutanil for severe cases",
|
||||
],
|
||||
preventionTips: [
|
||||
"Choose disease-resistant varieties (e.g., 'Knock Out', 'Drift', 'Easy Elegance')",
|
||||
"Water at soil level in the morning",
|
||||
"Space roses adequately (2-3 feet apart for most varieties)",
|
||||
"Remove and dispose of fallen leaves in fall",
|
||||
"Apply dormant spray (lime sulfur) in late winter",
|
||||
],
|
||||
severity: "high",
|
||||
},
|
||||
{
|
||||
id: "rose-powdery-mildew",
|
||||
name: "Powdery Mildew",
|
||||
scientificName: "Podosphaera pannosa",
|
||||
type: "fungal",
|
||||
description:
|
||||
"A common fungal disease that appears as a white powdery coating on leaves, stems, and buds. Unlike most fungal diseases, it thrives in warm, dry conditions with high humidity at night.",
|
||||
symptoms: [
|
||||
"White to gray powdery coating on leaves, stems, and buds",
|
||||
"Leaves may curl, twist, or become distorted",
|
||||
"New growth appears stunted and dwarfed",
|
||||
"Buds may fail to open or produce distorted flowers",
|
||||
"Purple-red discoloration on leaves in some varieties",
|
||||
],
|
||||
causes: [
|
||||
"Fungus thrives in warm days (70-85°F) and cool, humid nights",
|
||||
"Poor air circulation around plants",
|
||||
"New, succulent growth is most susceptible",
|
||||
"Shaded conditions reduce air movement and drying",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Prune out infected shoots and buds promptly",
|
||||
"Apply sulfur-based fungicide or neem oil every 7-14 days",
|
||||
"Use potassium bicarbonate spray to disrupt fungal growth",
|
||||
"Improve air circulation by thinning crowded growth",
|
||||
"Apply horticultural oil (dormant or summer weight) as directed",
|
||||
],
|
||||
preventionTips: [
|
||||
"Plant in full sun with good airflow",
|
||||
"Avoid high-nitrogen fertilizers that promote soft, susceptible growth",
|
||||
"Water at soil level, not overhead",
|
||||
"Prune annually to keep center of plant open",
|
||||
"Choose resistant cultivars when available",
|
||||
],
|
||||
severity: "moderate",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "monstera",
|
||||
commonName: "Monstera",
|
||||
scientificName: "Monstera deliciosa",
|
||||
family: "Araceae",
|
||||
category: "houseplants",
|
||||
description:
|
||||
"The iconic Swiss cheese plant with its distinctive split leaves. A popular low-maintenance houseplant native to tropical Central America.",
|
||||
careSummary: "Bright indirect light, moderate watering (let top inch dry), well-draining soil, high humidity preferred.",
|
||||
imageEmoji: "🪴",
|
||||
diseases: [
|
||||
{
|
||||
id: "monstera-root-rot",
|
||||
name: "Root Rot",
|
||||
scientificName: "Various (Pythium, Phytophthora, Fusarium spp.)",
|
||||
type: "fungal",
|
||||
description:
|
||||
"The most common killer of monsteras and other houseplants. Root rot is caused by overwatering and fungal pathogens thriving in waterlogged soil.",
|
||||
symptoms: [
|
||||
"Yellowing leaves starting from the bottom of the plant",
|
||||
"Wilting despite moist soil (roots can't uptake water)",
|
||||
"Soft, mushy stems near the soil line",
|
||||
"Brown, mushy, or black roots (healthy roots are firm and white/tan)",
|
||||
"Foul, musty smell coming from the soil",
|
||||
"Stunted growth and dropping leaves",
|
||||
],
|
||||
causes: [
|
||||
"Overwatering — potting soil stays soggy for extended periods",
|
||||
"Poor drainage — pot lacks drainage holes or soil is too dense",
|
||||
"Pots that are too large relative to the root system",
|
||||
"Low light reducing water uptake by the plant",
|
||||
"Prolonged exposure to cold, damp conditions",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove plant from pot and cut away ALL mushy, brown roots",
|
||||
"Trim affected leaves and stems — reduce the plant's transpiration demand",
|
||||
"Wash remaining healthy roots with diluted hydrogen peroxide (1:3 with water)",
|
||||
"Repot in fresh, well-draining soil in a clean pot with drainage holes",
|
||||
"Do not water for 5-7 days after repotting, then water sparingly",
|
||||
"Treat with fungicide (copper-based or biological) if severe",
|
||||
],
|
||||
preventionTips: [
|
||||
"Water only when the top 1-2 inches of soil is dry",
|
||||
"Use pots with drainage holes and well-draining aroid mix",
|
||||
"Choose a pot only 1-2 inches larger than the root ball",
|
||||
"Provide bright, indirect light for proper water uptake",
|
||||
"Reduce watering in winter when growth slows",
|
||||
],
|
||||
severity: "critical",
|
||||
},
|
||||
{
|
||||
id: "monstera-leaf-spot",
|
||||
name: "Bacterial Leaf Spot",
|
||||
scientificName: "Pseudomonas cichorii",
|
||||
type: "bacterial",
|
||||
description:
|
||||
"A common bacterial infection that causes unsightly spots on monstera leaves. It thrives in warm, humid conditions with poor air circulation.",
|
||||
symptoms: [
|
||||
"Dark brown to black spots with yellow halos on leaves",
|
||||
"Spots may be water-soaked initially, turning dry and crispy",
|
||||
"Irregularly shaped spots, often along leaf edges or veins",
|
||||
"Spots may merge to form large dead areas on leaves",
|
||||
"Leaves may yellow and drop prematurely",
|
||||
],
|
||||
causes: [
|
||||
"Bacteria enters through wounds or natural openings (stomata)",
|
||||
"Overhead watering splashing bacteria onto leaves",
|
||||
"High humidity combined with stagnant air",
|
||||
"Using contaminated pruning tools",
|
||||
"Overcrowding with poor air circulation",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Isolate affected plant from other houseplants",
|
||||
"Cut off severely affected leaves with sterilized scissors",
|
||||
"Apply copper-based bactericide to remaining leaves",
|
||||
"Improve air circulation with a fan on low setting",
|
||||
"Allow soil to dry slightly between waterings",
|
||||
],
|
||||
preventionTips: [
|
||||
"Avoid overhead watering — water at soil level",
|
||||
"Maintain good air circulation around plants",
|
||||
"Sterilize pruning tools between plants",
|
||||
"Quarantine new plants for 2-3 weeks before introducing",
|
||||
"Keep leaves dry, especially in humid conditions",
|
||||
],
|
||||
severity: "moderate",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "snake-plant",
|
||||
commonName: "Snake Plant",
|
||||
scientificName: "Dracaena trifasciata (formerly Sansevieria)",
|
||||
family: "Asparagaceae",
|
||||
category: "houseplants",
|
||||
description:
|
||||
"One of the hardiest houseplants, snake plants are almost impossible to kill. They're known for their upright, sword-like leaves and air-purifying ability.",
|
||||
careSummary: "Low to bright indirect light, infrequent watering (let soil dry completely), drought-tolerant.",
|
||||
imageEmoji: "🌵",
|
||||
diseases: [
|
||||
{
|
||||
id: "snake-plant-overwatering",
|
||||
name: "Overwatering / Root Rot",
|
||||
type: "physiological",
|
||||
description:
|
||||
"Snake plants are succulents and the #1 cause of death is overwatering. They store water in their leaves and need the soil to dry completely between waterings.",
|
||||
symptoms: [
|
||||
"Leaves turning yellow, soft, or mushy at the base",
|
||||
"Leaves falling over or flopping",
|
||||
"Brown, mushy roots when checked",
|
||||
"Foul smell from the soil indicating rot",
|
||||
"Leaf tips turning brown while base is yellow",
|
||||
],
|
||||
causes: [
|
||||
"Watering too frequently (snake plants need infrequent watering)",
|
||||
"Soil that doesn't drain well or is too compacted",
|
||||
"Pot without drainage holes trapping water at bottom",
|
||||
"Cold temperatures combined with wet soil",
|
||||
"Pot too large causing soil to stay wet too long",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Stop watering immediately and let soil dry completely",
|
||||
"Remove plant, cut away all rotted roots and affected leaves",
|
||||
"Repot in fresh succulent/cactus mix in a pot with drainage holes",
|
||||
"Wait 2-3 weeks before first light watering",
|
||||
"Place in bright indirect light to encourage recovery",
|
||||
],
|
||||
preventionTips: [
|
||||
"Water only when soil is completely dry (every 2-6 weeks depending on conditions)",
|
||||
"Use well-draining succulent or cactus potting mix",
|
||||
"Choose a pot only slightly larger than the root ball with drainage holes",
|
||||
"Water even less in winter (monthly or less)",
|
||||
"When in doubt, don't water — snake plants tolerate drought far better than overwatering",
|
||||
],
|
||||
severity: "high",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pepper",
|
||||
commonName: "Bell Pepper",
|
||||
scientificName: "Capsicum annuum",
|
||||
family: "Solanaceae",
|
||||
category: "vegetables",
|
||||
description:
|
||||
"Colorful sweet peppers are a garden favorite. Like tomatoes, they're in the nightshade family and share some similar disease vulnerabilities.",
|
||||
careSummary: "Full sun, warm soil (65°F+), consistent moisture, stake for support, heavy feeder.",
|
||||
imageEmoji: "🫑",
|
||||
diseases: [
|
||||
{
|
||||
id: "pepper-bacterial-spot",
|
||||
name: "Bacterial Leaf Spot",
|
||||
scientificName: "Xanthomonas campestris pv. vesicatoria",
|
||||
type: "bacterial",
|
||||
description:
|
||||
"A serious bacterial disease affecting peppers in warm, humid weather. It can cause significant defoliation and fruit blemishing, reducing yield and marketability.",
|
||||
symptoms: [
|
||||
"Small, water-soaked spots on leaves that turn brown to black",
|
||||
"Spots have a distinctive yellow halo",
|
||||
"Leaf spots may fall out, creating a 'shot-hole' appearance",
|
||||
"Raised, scabby spots on fruit with green to brown discoloration",
|
||||
"Severe defoliation starting from the bottom of the plant",
|
||||
],
|
||||
causes: [
|
||||
"Bacteria spreads through splashing water and rain",
|
||||
"Warm temperatures (75-95°F / 24-35°C) with high humidity",
|
||||
"Overhead irrigation that keeps foliage wet",
|
||||
"Working with wet plants spreads bacteria",
|
||||
"Contaminated seeds or transplants",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy severely infected leaves and plants",
|
||||
"Apply copper-based bactericide (copper hydroxide or copper sulfate)",
|
||||
"Use streptomycin sulfate bactericide if available (check local regulations)",
|
||||
"Switch to drip irrigation immediately",
|
||||
"Avoid working with plants when they are wet",
|
||||
],
|
||||
preventionTips: [
|
||||
"Use disease-free seeds (hot water treatment at 125°F/52°C for 30 minutes)",
|
||||
"Choose resistant varieties (e.g., 'Boynton Bell', 'Summer Sweet')",
|
||||
"Rotate crops on a 3-year cycle",
|
||||
"Use plastic mulch to prevent soil splash",
|
||||
"Water in the morning with drip irrigation only",
|
||||
],
|
||||
severity: "high",
|
||||
},
|
||||
{
|
||||
id: "pepper-sunscald",
|
||||
name: "Sunscald",
|
||||
type: "physiological",
|
||||
description:
|
||||
"Not a disease but a physiological disorder caused by excessive sun exposure on pepper fruit, usually after defoliation from other issues.",
|
||||
symptoms: [
|
||||
"Soft, pale white or yellow patches on the sun-exposed side of fruit",
|
||||
"Affected area becomes papery and sunken over time",
|
||||
"Secondary fungal infection may develop on sunscalded areas (gray or black mold)",
|
||||
"Occurs most often on fruit exposed after leaf loss",
|
||||
],
|
||||
causes: [
|
||||
"Intense direct sunlight on fruit surfaces",
|
||||
"Sudden loss of leaf cover (from pruning, disease, or pest damage)",
|
||||
"Fruit oriented toward the south or west side of the plant",
|
||||
"Extremely hot weather combined with low humidity",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Provide shade cloth (30-40%) during extreme heat events",
|
||||
"Leave adequate foliage cover — avoid over-pruning",
|
||||
"Harvest affected fruit promptly (they won't recover)",
|
||||
"Treat secondary fungal infections if they appear",
|
||||
"Use kaolin clay spray (Surround WP) to reflect excess light",
|
||||
],
|
||||
preventionTips: [
|
||||
"Maintain healthy foliage cover — don't over-prune",
|
||||
"Use shade cloth during heat waves (90°F+/32°C+)",
|
||||
"Plant in location with some afternoon shade in hot climates",
|
||||
"Space plants so they provide mutual leaf cover",
|
||||
"Control defoliating diseases and pests promptly",
|
||||
],
|
||||
severity: "low",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lavender",
|
||||
commonName: "Lavender",
|
||||
scientificName: "Lavandula angustifolia",
|
||||
family: "Lamiaceae",
|
||||
category: "herbs",
|
||||
description:
|
||||
"A beloved aromatic herb from the Mediterranean. Lavender is drought-tolerant and thrives in poor, well-drained soil. Most problems come from too much water or humidity.",
|
||||
careSummary: "Full sun, very well-drained alkaline soil, minimal water once established, prune after flowering.",
|
||||
imageEmoji: "💜",
|
||||
diseases: [
|
||||
{
|
||||
id: "lavender-root-rot",
|
||||
name: "Root Rot / Shab",
|
||||
scientificName: "Phytophthora spp. / Various fungi",
|
||||
type: "fungal",
|
||||
description:
|
||||
"The most common cause of lavender death. Lavender evolved in dry, rocky Mediterranean slopes and cannot tolerate wet roots. Root rot can kill a plant within days.",
|
||||
symptoms: [
|
||||
"Leaves turning gray or yellow and wilting",
|
||||
"Plant looks like it needs water but soil is moist",
|
||||
"Stems near ground become brown and woody",
|
||||
"Base of plant turns black and mushy",
|
||||
"Sudden collapse of entire plant in severe cases",
|
||||
"Foul smell from soil",
|
||||
],
|
||||
causes: [
|
||||
"Overwatering or planting in poorly drained soil",
|
||||
"Heavy clay soil retaining too much moisture",
|
||||
"High humidity combined with poor air circulation",
|
||||
"Wet winter soil is especially deadly for lavender",
|
||||
"Mulch piled against the crown causing rot",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy severely affected plants — rarely recoverable",
|
||||
"For mild cases: stop watering, dig up, cut away rotted roots",
|
||||
"Repot in extremely well-draining mix (cactus/succulent + perlite)",
|
||||
"Add gravel or grit to planting hole for drainage",
|
||||
"Do not water for 1-2 weeks after repotting",
|
||||
],
|
||||
preventionTips: [
|
||||
"Plant in raised beds or slopes with excellent drainage",
|
||||
"Add gravel, sand or grit to heavy soil before planting",
|
||||
"Water deeply but infrequently — let soil dry between waterings",
|
||||
"Never mulch around lavender crown — keep base exposed",
|
||||
"Space plants 18-24 inches apart for airflow",
|
||||
"In humid climates, choose cultivars like 'Phenomenal' or 'Grosso'",
|
||||
],
|
||||
severity: "high",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sunflower",
|
||||
commonName: "Sunflower",
|
||||
scientificName: "Helianthus annuus",
|
||||
family: "Asteraceae",
|
||||
category: "flowers",
|
||||
description:
|
||||
"Cheerful, fast-growing annuals that are surprisingly resilient. Sunflowers are generally low-maintenance but can be affected by a few diseases, especially in crowded plantings.",
|
||||
careSummary: "Full sun (6-8+ hours), moderately fertile well-drained soil, support tall varieties, protect from strong winds.",
|
||||
imageEmoji: "🌻",
|
||||
diseases: [
|
||||
{
|
||||
id: "sunflower-downy-mildew",
|
||||
name: "Downy Mildew",
|
||||
scientificName: "Plasmopara halstedii",
|
||||
type: "fungal",
|
||||
description:
|
||||
"A serious disease affecting sunflower seedlings. It stunts growth significantly and can reduce yields in agricultural settings.",
|
||||
symptoms: [
|
||||
"Stunted, thickened, pale green to yellow leaves",
|
||||
"White fuzzy growth on leaf undersides",
|
||||
"Plants are severely dwarfed with shortened internodes",
|
||||
"Leaves curl downward and become brittle",
|
||||
"Infected plants rarely produce flowers or produce tiny heads",
|
||||
],
|
||||
causes: [
|
||||
"Soil-borne oospores infect young seedlings in cool, wet soil",
|
||||
"Cool soil temperatures (50-65°F / 10-18°C) at planting time",
|
||||
"Prolonged wet weather after planting",
|
||||
"Poorly drained soil",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy infected seedlings immediately",
|
||||
"Improve drainage if possible",
|
||||
"Apply metalaxyl or mefenoxam fungicide as soil treatment (for high-value plantings)",
|
||||
"Do not plant sunflowers in same spot for 5-7 years",
|
||||
],
|
||||
preventionTips: [
|
||||
"Plant resistant varieties (most modern hybrids have resistance)",
|
||||
"Wait until soil is warm (60°F+) before planting",
|
||||
"Plant in well-drained soil — avoid low, wet areas",
|
||||
"Use fungicide seed treatments for high-risk areas",
|
||||
"Rotate sunflowers with non-host crops (corn, soybeans, wheat)",
|
||||
],
|
||||
severity: "high",
|
||||
},
|
||||
{
|
||||
id: "sunflower-rust",
|
||||
name: "Rust",
|
||||
scientificName: "Puccinia helianthi",
|
||||
type: "fungal",
|
||||
description:
|
||||
"A common fungal disease that produces rusty orange pustules on leaves. While usually not fatal, severe infections can reduce vigor and flower quality.",
|
||||
symptoms: [
|
||||
"Small, rusty orange to brown pustules (raised bumps) on leaves",
|
||||
"Pustules appear mainly on leaf undersides, with yellow spots on top",
|
||||
"Leaves turn yellow, then brown, and may die prematurely",
|
||||
"In severe cases, pustules appear on stems and flower bracts",
|
||||
"Defoliation from bottom upward as disease progresses",
|
||||
],
|
||||
causes: [
|
||||
"Rust fungus spores overwinter on infected plant debris",
|
||||
"Spores spread by wind and splashing water",
|
||||
"Warm, humid weather (65-85°F / 18-29°C) with frequent dew or rain",
|
||||
"Overcrowding reducing air circulation",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy heavily infected leaves",
|
||||
"Apply sulfur-based fungicide or neem oil at first sign",
|
||||
"Improve spacing and air circulation",
|
||||
"Water at soil level, not overhead",
|
||||
"Apply fungicide containing myclobutanil or chlorothalonil for severe cases",
|
||||
],
|
||||
preventionTips: [
|
||||
"Space plants 12-24 inches apart for good airflow",
|
||||
"Plant in full sun to ensure leaves dry quickly",
|
||||
"Water in the morning at the base of plants",
|
||||
"Clean up all plant debris at end of season",
|
||||
"Rotate planting location each year",
|
||||
],
|
||||
severity: "low",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a plant by its ID.
|
||||
*/
|
||||
export function getPlantById(id: string): Plant | undefined {
|
||||
return plants.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plants by category.
|
||||
*/
|
||||
export function getPlantsByCategory(category: Plant["category"]): Plant[] {
|
||||
return plants.filter((p) => p.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured plants (subset for homepage).
|
||||
*/
|
||||
export function getFeaturedPlants(): Plant[] {
|
||||
return plants.filter((p) =>
|
||||
["tomato", "basil", "rose", "monstera", "snake-plant", "pepper"].includes(p.id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique disease types used across the dataset.
|
||||
*/
|
||||
export function getAllDiseaseTypes(): Disease["type"][] {
|
||||
const types = new Set<Disease["type"]>();
|
||||
plants.forEach((p) => p.diseases.forEach((d) => types.add(d.type)));
|
||||
return Array.from(types);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search plants by name (case-insensitive).
|
||||
*/
|
||||
export function searchPlants(query: string): Plant[] {
|
||||
const q = query.toLowerCase().trim();
|
||||
if (!q) return plants;
|
||||
return plants.filter(
|
||||
(p) =>
|
||||
p.commonName.toLowerCase().includes(q) ||
|
||||
p.scientificName.toLowerCase().includes(q) ||
|
||||
p.diseases.some((d) => d.name.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
0
apps/web/src/lib/.gitkeep
Normal file
0
apps/web/src/lib/.gitkeep
Normal file
0
apps/web/src/lib/api/.gitkeep
Normal file
0
apps/web/src/lib/api/.gitkeep
Normal file
315
apps/web/src/lib/api/diseases.ts
Normal file
315
apps/web/src/lib/api/diseases.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Typed helpers to query the plant disease knowledge base.
|
||||
* All functions operate on the JSON seed data files.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CausalAgentType,
|
||||
Disease,
|
||||
DiseaseListParams,
|
||||
DiseaseWithPlant,
|
||||
Plant,
|
||||
PlantListParams,
|
||||
PlantWithDiseases,
|
||||
Severity,
|
||||
} from "@/lib/types";
|
||||
|
||||
import rawPlants from "@/data/plants.json";
|
||||
import rawDiseases from "@/data/diseases.json";
|
||||
|
||||
// Cast JSON imports to typed arrays
|
||||
const plants: Plant[] = rawPlants as Plant[];
|
||||
const diseases: Disease[] = rawDiseases as Disease[];
|
||||
|
||||
// Re-export raw data for direct access if needed
|
||||
export { plants, diseases };
|
||||
|
||||
// Lookup maps for O(1) access
|
||||
const plantMap = new Map(plants.map((p) => [p.id, p]));
|
||||
const diseaseMap = new Map(diseases.map((d) => [d.id, d]));
|
||||
|
||||
/**
|
||||
* Get a plant by its ID.
|
||||
* @returns The plant or undefined if not found.
|
||||
*/
|
||||
export function getPlantById(id: string): Plant | undefined {
|
||||
return plantMap.get(id.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a disease by its ID.
|
||||
* @returns The disease or undefined if not found.
|
||||
*/
|
||||
export function getDiseaseById(id: string): Disease | undefined {
|
||||
return diseaseMap.get(id.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all diseases for a specific plant.
|
||||
* @returns Array of diseases for the plant.
|
||||
*/
|
||||
export function getDiseasesByPlantId(plantId: string): Disease[] {
|
||||
return diseases.filter(
|
||||
(d) => d.plantId.toLowerCase() === plantId.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a plant with all its associated diseases.
|
||||
* @returns PlantWithDiseases or undefined if plant not found.
|
||||
*/
|
||||
export function getPlantWithDiseases(
|
||||
plantId: string
|
||||
): PlantWithDiseases | undefined {
|
||||
const plant = getPlantById(plantId);
|
||||
if (!plant) return undefined;
|
||||
return {
|
||||
plant,
|
||||
diseases: getDiseasesByPlantId(plantId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a disease with its associated plant.
|
||||
* @returns DiseaseWithPlant or undefined if disease not found.
|
||||
*/
|
||||
export function getDiseaseWithPlant(
|
||||
diseaseId: string
|
||||
): DiseaseWithPlant | undefined {
|
||||
const disease = getDiseaseById(diseaseId);
|
||||
if (!disease) return undefined;
|
||||
const plant = getPlantById(disease.plantId);
|
||||
if (!plant) return undefined;
|
||||
return { disease, plant };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve lookalike disease IDs to full disease objects.
|
||||
* @returns Array of lookalike diseases.
|
||||
*/
|
||||
export function getLookalikeDiseases(diseaseId: string): Disease[] {
|
||||
const disease = getDiseaseById(diseaseId);
|
||||
if (!disease || !disease.lookalikeDiseaseIds.length) return [];
|
||||
return disease.lookalikeDiseaseIds
|
||||
.map((id) => getDiseaseById(id))
|
||||
.filter((d): d is Disease => d !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search plants by term (matches common name, scientific name, family, category).
|
||||
* @param term - Search term (case-insensitive).
|
||||
* @returns Matching plants.
|
||||
*/
|
||||
export function searchPlants(term: string): Plant[] {
|
||||
const lower = term.toLowerCase().trim();
|
||||
if (!lower) return plants;
|
||||
return plants.filter(
|
||||
(p) =>
|
||||
p.commonName.toLowerCase().includes(lower) ||
|
||||
p.scientificName.toLowerCase().includes(lower) ||
|
||||
p.family.toLowerCase().includes(lower) ||
|
||||
p.category.toLowerCase().includes(lower)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search diseases by term (matches name, scientific name, description, symptoms).
|
||||
* @param term - Search term (case-insensitive).
|
||||
* @returns Matching diseases.
|
||||
*/
|
||||
export function searchDiseases(term: string): Disease[] {
|
||||
const lower = term.toLowerCase().trim();
|
||||
if (!lower) return diseases;
|
||||
return diseases.filter(
|
||||
(d) =>
|
||||
d.name.toLowerCase().includes(lower) ||
|
||||
d.scientificName.toLowerCase().includes(lower) ||
|
||||
d.description.toLowerCase().includes(lower) ||
|
||||
d.symptoms.some((s) => s.toLowerCase().includes(lower))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List plants with optional search and category filters.
|
||||
*/
|
||||
export function listPlants(params: PlantListParams = {}): Plant[] {
|
||||
let result = plants;
|
||||
if (params.category) {
|
||||
result = result.filter(
|
||||
(p) => p.category === params.category
|
||||
);
|
||||
}
|
||||
if (params.search) {
|
||||
const lower = params.search.toLowerCase().trim();
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.commonName.toLowerCase().includes(lower) ||
|
||||
p.scientificName.toLowerCase().includes(lower) ||
|
||||
p.family.toLowerCase().includes(lower) ||
|
||||
p.category.toLowerCase().includes(lower)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* List diseases with optional filters.
|
||||
*/
|
||||
export function listDiseases(params: DiseaseListParams = {}): Disease[] {
|
||||
let result = diseases;
|
||||
if (params.plantId) {
|
||||
result = result.filter(
|
||||
(d) => d.plantId.toLowerCase() === params.plantId!.toLowerCase()
|
||||
);
|
||||
}
|
||||
if (params.causalAgentType) {
|
||||
result = result.filter(
|
||||
(d) => d.causalAgentType === params.causalAgentType
|
||||
);
|
||||
}
|
||||
if (params.severity) {
|
||||
result = result.filter((d) => d.severity === params.severity);
|
||||
}
|
||||
if (params.search) {
|
||||
const lower = params.search.toLowerCase().trim();
|
||||
result = result.filter(
|
||||
(d) =>
|
||||
d.name.toLowerCase().includes(lower) ||
|
||||
d.scientificName.toLowerCase().includes(lower) ||
|
||||
d.description.toLowerCase().includes(lower) ||
|
||||
d.symptoms.some((s) => s.toLowerCase().includes(lower))
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique plant IDs that have diseases.
|
||||
*/
|
||||
export function getPlantIdsWithDiseases(): string[] {
|
||||
return [...new Set(diseases.map((d) => d.plantId))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique disease IDs referenced as lookalikes.
|
||||
*/
|
||||
export function getReferencedLookalikeIds(): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const disease of diseases) {
|
||||
for (const lookalikeId of disease.lookalikeDiseaseIds) {
|
||||
ids.add(lookalikeId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate knowledge base data integrity.
|
||||
* @returns Array of validation errors (empty = valid).
|
||||
*/
|
||||
export function validateKnowledgeBase(): string[] {
|
||||
const errors: string[] = [];
|
||||
const validCausalAgentTypes: CausalAgentType[] = [
|
||||
"fungal",
|
||||
"bacterial",
|
||||
"viral",
|
||||
"environmental",
|
||||
];
|
||||
const validSeverities: Severity[] = ["low", "moderate", "high", "critical"];
|
||||
|
||||
// Check all plant IDs are unique
|
||||
const plantIds = new Set<string>();
|
||||
for (const plant of plants) {
|
||||
if (plantIds.has(plant.id)) {
|
||||
errors.push(`Duplicate plant ID: ${plant.id}`);
|
||||
}
|
||||
plantIds.add(plant.id);
|
||||
}
|
||||
|
||||
// Check all disease IDs are unique
|
||||
const diseaseIds = new Set<string>();
|
||||
for (const disease of diseases) {
|
||||
if (diseaseIds.has(disease.id)) {
|
||||
errors.push(`Duplicate disease ID: ${disease.id}`);
|
||||
}
|
||||
diseaseIds.add(disease.id);
|
||||
}
|
||||
|
||||
// Check each disease
|
||||
for (const disease of diseases) {
|
||||
// Valid plant reference
|
||||
if (!plantIds.has(disease.plantId)) {
|
||||
errors.push(
|
||||
`Disease "${disease.id}" references unknown plant ID: ${disease.plantId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Valid causal agent type
|
||||
if (!validCausalAgentTypes.includes(disease.causalAgentType)) {
|
||||
errors.push(
|
||||
`Disease "${disease.id}" has invalid causalAgentType: ${disease.causalAgentType}`
|
||||
);
|
||||
}
|
||||
|
||||
// Valid severity
|
||||
if (!validSeverities.includes(disease.severity)) {
|
||||
errors.push(
|
||||
`Disease "${disease.id}" has invalid severity: ${disease.severity}`
|
||||
);
|
||||
}
|
||||
|
||||
// Minimum symptom count
|
||||
if (disease.symptoms.length < 3) {
|
||||
errors.push(
|
||||
`Disease "${disease.id}" has fewer than 3 symptoms (${disease.symptoms.length})`
|
||||
);
|
||||
}
|
||||
|
||||
// Minimum cause count
|
||||
if (disease.causes.length < 2) {
|
||||
errors.push(
|
||||
`Disease "${disease.id}" has fewer than 2 causes (${disease.causes.length})`
|
||||
);
|
||||
}
|
||||
|
||||
// Minimum treatment count
|
||||
if (disease.treatment.length < 3) {
|
||||
errors.push(
|
||||
`Disease "${disease.id}" has fewer than 3 treatment steps (${disease.treatment.length})`
|
||||
);
|
||||
}
|
||||
|
||||
// Minimum prevention count
|
||||
if (disease.prevention.length < 2) {
|
||||
errors.push(
|
||||
`Disease "${disease.id}" has fewer than 2 prevention tips (${disease.prevention.length})`
|
||||
);
|
||||
}
|
||||
|
||||
// Valid lookalike references
|
||||
for (const lookalikeId of disease.lookalikeDiseaseIds) {
|
||||
if (!diseaseIds.has(lookalikeId)) {
|
||||
errors.push(
|
||||
`Disease "${disease.id}" references unknown lookalike: ${lookalikeId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check lookalike bidirectionality (optional warning, not error)
|
||||
for (const disease of diseases) {
|
||||
for (const lookalikeId of disease.lookalikeDiseaseIds) {
|
||||
const lookalike = getDiseaseById(lookalikeId);
|
||||
if (
|
||||
lookalike &&
|
||||
!lookalike.lookalikeDiseaseIds.includes(disease.id)
|
||||
) {
|
||||
errors.push(
|
||||
`Lookalike reference not bidirectional: "${disease.id}" references "${lookalikeId}" but not vice versa`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
49
apps/web/src/lib/api/identify.ts
Normal file
49
apps/web/src/lib/api/identify.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Client-side API helper for plant disease identification.
|
||||
*
|
||||
* POSTs an imageId to the /api/identify endpoint and returns
|
||||
* ranked predictions with confidence scores, enriched with
|
||||
* knowledge base data (name, symptoms, treatment, prevention).
|
||||
*/
|
||||
|
||||
import type { IdentifyRequest, IdentifyResponse } from "@/lib/types";
|
||||
|
||||
export interface IdentifyError {
|
||||
error: string;
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify plant diseases from an uploaded image.
|
||||
*
|
||||
* @param imageId - Image ID from a previous /api/upload call
|
||||
* @returns IdentifyResponse with ranked predictions and metadata
|
||||
* @throws IdentifyError on failure
|
||||
*/
|
||||
export async function identifyPlant(
|
||||
imageId: string,
|
||||
): Promise<IdentifyResponse> {
|
||||
const request: IdentifyRequest = { imageId };
|
||||
|
||||
const response = await fetch("/api/identify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal: AbortSignal.timeout(30_000), // 30s timeout for inference
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw {
|
||||
error: data.error || "Identification failed",
|
||||
message: data.message || `Server returned ${response.status}`,
|
||||
status: response.status,
|
||||
} as IdentifyError;
|
||||
}
|
||||
|
||||
return data as IdentifyResponse;
|
||||
}
|
||||
89
apps/web/src/lib/api/upload.ts
Normal file
89
apps/web/src/lib/api/upload.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Client-side API helper for image upload.
|
||||
*
|
||||
* POSTs an image file to the server upload endpoint and returns
|
||||
* the image metadata (imageId, tensorShape, previewUrl).
|
||||
*/
|
||||
|
||||
import { validateImageFile, validateImageDimensions } from "@/lib/image-processing";
|
||||
|
||||
export interface UploadResponse {
|
||||
imageId: string;
|
||||
tensorShape: [number, number, number, number];
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
export interface UploadError {
|
||||
error: string;
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image file to the server.
|
||||
*
|
||||
* Validates the file client-side before sending. Returns the server
|
||||
* response with imageId, tensorShape, and previewUrl.
|
||||
*
|
||||
* @param file - The image file to upload
|
||||
* @param onProgress - Optional callback for upload progress (0–100)
|
||||
* @returns UploadResponse on success
|
||||
* @throws UploadError on failure
|
||||
*/
|
||||
export async function uploadImage(
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<UploadResponse> {
|
||||
// Client-side validation
|
||||
const fileValidation = validateImageFile(file);
|
||||
if (!fileValidation.ok) {
|
||||
throw new Error(`Validation: ${fileValidation.error}`);
|
||||
}
|
||||
|
||||
const dimValidation = await validateImageDimensions(file);
|
||||
if (!dimValidation.ok) {
|
||||
throw new Error(`Validation: ${dimValidation.error}`);
|
||||
}
|
||||
|
||||
// Build form data
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
|
||||
// POST with progress tracking
|
||||
const response = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(30_000), // 30s timeout
|
||||
});
|
||||
|
||||
// Parse response
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw {
|
||||
error: data.error || "Upload failed",
|
||||
message: data.message || `Server returned ${response.status}`,
|
||||
status: response.status,
|
||||
} as UploadError;
|
||||
}
|
||||
|
||||
return data as UploadResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image and also get the preprocessed tensor for client-side inference.
|
||||
* Useful when you want to run inference on the client while also uploading.
|
||||
*
|
||||
* @param file - The image file to upload
|
||||
* @returns { uploadResponse, tensor }
|
||||
*/
|
||||
export async function uploadWithTensor(
|
||||
file: File,
|
||||
): Promise<{ uploadResponse: UploadResponse; tensor: Float32Array }> {
|
||||
const { resizeImage, imageToTensor } = await import("@/lib/image-processing");
|
||||
|
||||
const tensor = imageToTensor(await resizeImage(file));
|
||||
const uploadResponse = await uploadImage(file);
|
||||
|
||||
return { uploadResponse, tensor };
|
||||
}
|
||||
69
apps/web/src/lib/constants.ts
Normal file
69
apps/web/src/lib/constants.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Site-wide constants for the Plant Disease Identifier application.
|
||||
*/
|
||||
|
||||
export const APP_NAME = "Plant Health ID";
|
||||
export const APP_TAGLINE = "Snap. Identify. Treat.";
|
||||
export const APP_DESCRIPTION =
|
||||
"Upload a plant photo for hyper-specific disease diagnosis with confidence scores, symptoms, causes, treatment steps, and prevention tips.";
|
||||
|
||||
export const SOCIAL_LINKS = {
|
||||
github: "https://github.com/plant-health-id",
|
||||
twitter: "https://twitter.com/planthealthid",
|
||||
} as const;
|
||||
|
||||
export const NAV_LINKS = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/upload", label: "Identify" },
|
||||
{ href: "/browse", label: "Browse Plants" },
|
||||
{ href: "/about", label: "About" },
|
||||
] as const;
|
||||
|
||||
export const PLANT_CATEGORIES = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "vegetables", label: "Vegetables" },
|
||||
{ value: "herbs", label: "Herbs" },
|
||||
{ value: "houseplants", label: "Houseplants" },
|
||||
{ value: "flowers", label: "Flowers" },
|
||||
] as const;
|
||||
|
||||
export const FEATURED_PLANT_IDS = [
|
||||
"tomato",
|
||||
"basil",
|
||||
"rose",
|
||||
"monstera",
|
||||
"snake-plant",
|
||||
"pepper",
|
||||
] as const;
|
||||
|
||||
export const TRUST_SIGNALS = [
|
||||
{ icon: "📸", label: "Trained on 50K+ images" },
|
||||
{ icon: "🌿", label: "Covers 25+ plants" },
|
||||
{ icon: "🔓", label: "Open source" },
|
||||
] as const;
|
||||
|
||||
export const HOW_IT_WORKS = [
|
||||
{
|
||||
step: 1,
|
||||
emoji: "📸",
|
||||
title: "Upload a Photo",
|
||||
description: "Snap a picture of the affected plant leaf or fruit with your phone or camera.",
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
emoji: "🧠",
|
||||
title: "AI Analysis",
|
||||
description:
|
||||
"Our model analyzes the image against 50K+ labeled plant disease images in seconds.",
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
emoji: "🌱",
|
||||
title: "Get Treatment Plan",
|
||||
description:
|
||||
"Receive a detailed diagnosis with confidence score, symptoms, causes, and step-by-step treatment.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const BETA_DISCLAIMER =
|
||||
"Plant Health ID is an AI-assisted tool for informational purposes only. It is not a substitute for professional agricultural or horticultural advice. Always consult a certified plant pathologist or extension service for critical plant health decisions.";
|
||||
241
apps/web/src/lib/image-processing.test.ts
Normal file
241
apps/web/src/lib/image-processing.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Unit tests for lib/image-processing.ts
|
||||
*
|
||||
* Tests:
|
||||
* - resizeImage() produces 224×224 output for any input aspect ratio
|
||||
* - imageToTensor() output length equals 3 * 224 * 224
|
||||
* - Normalization produces values in expected range
|
||||
* - validateImageFile rejects invalid types and oversized files
|
||||
* - tensorToBase64 / base64ToTensor round-trip
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
resizeImage,
|
||||
imageToTensor,
|
||||
tensorToBase64,
|
||||
base64ToTensor,
|
||||
getTensorShape,
|
||||
validateImageFile,
|
||||
MAX_FILE_SIZE,
|
||||
MIN_DIMENSION,
|
||||
ALLOWED_MIME_TYPES,
|
||||
} from "@/lib/image-processing";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Create a mock ImageData at given dimensions */
|
||||
function createMockImageData(
|
||||
width: number,
|
||||
height: number,
|
||||
fillR = 128,
|
||||
fillG = 64,
|
||||
fillB = 32,
|
||||
): ImageData {
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
data[i * 4] = fillR;
|
||||
data[i * 4 + 1] = fillG;
|
||||
data[i * 4 + 2] = fillB;
|
||||
data[i * 4 + 3] = 255;
|
||||
}
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
/** Create a mock File with given properties */
|
||||
function createMockFile({
|
||||
name = "test.jpg",
|
||||
type = "image/jpeg",
|
||||
size = 1024,
|
||||
}: Partial<File> & Pick<File, "name" | "type" | "size"> = {}): File {
|
||||
// Use ArrayBuffer to control actual file size for large/empty tests
|
||||
let content: BlobPart;
|
||||
if (size === 0) {
|
||||
content = "";
|
||||
} else if (size > 1024) {
|
||||
// For large files, create a buffer of the right size
|
||||
content = new Uint8Array(size);
|
||||
} else {
|
||||
content = "dummy";
|
||||
}
|
||||
return new File([content], name, { type });
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("validateImageFile", () => {
|
||||
it("accepts valid JPEG", () => {
|
||||
const file = createMockFile({ name: "photo.jpg", type: "image/jpeg", size: 1024 });
|
||||
const result = validateImageFile(file);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts valid PNG", () => {
|
||||
const file = createMockFile({ name: "photo.png", type: "image/png", size: 1024 });
|
||||
const result = validateImageFile(file);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts valid WebP", () => {
|
||||
const file = createMockFile({ name: "photo.webp", type: "image/webp", size: 1024 });
|
||||
const result = validateImageFile(file);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsupported MIME type", () => {
|
||||
const file = createMockFile({ name: "document.txt", type: "text/plain", size: 1024 });
|
||||
const result = validateImageFile(file);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("Unsupported file type");
|
||||
});
|
||||
|
||||
it("rejects files larger than 10 MB", () => {
|
||||
const file = createMockFile({
|
||||
name: "huge.jpg",
|
||||
type: "image/jpeg",
|
||||
size: 11 * 1024 * 1024, // 11 MB
|
||||
});
|
||||
const result = validateImageFile(file);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("too large");
|
||||
});
|
||||
|
||||
it("rejects empty files", () => {
|
||||
const file = createMockFile({ name: "empty.jpg", type: "image/jpeg", size: 0 });
|
||||
const result = validateImageFile(file);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("empty");
|
||||
});
|
||||
});
|
||||
|
||||
describe("imageToTensor", () => {
|
||||
it("produces correct tensor length for 224×224", () => {
|
||||
const imageData = createMockImageData(224, 224);
|
||||
const tensor = imageToTensor(imageData);
|
||||
expect(tensor.length).toBe(3 * 224 * 224);
|
||||
});
|
||||
|
||||
it("produces correct tensor length for 299×299", () => {
|
||||
const imageData = createMockImageData(299, 299);
|
||||
const tensor = imageToTensor(imageData);
|
||||
expect(tensor.length).toBe(3 * 299 * 299);
|
||||
});
|
||||
|
||||
it("produces Float32Array", () => {
|
||||
const imageData = createMockImageData(224, 224);
|
||||
const tensor = imageToTensor(imageData);
|
||||
expect(tensor).toBeInstanceOf(Float32Array);
|
||||
});
|
||||
|
||||
it("normalizes pixel values with ImageNet mean/std", () => {
|
||||
// All pixels set to 128/255 = 0.502
|
||||
const imageData = createMockImageData(224, 224, 128, 128, 128);
|
||||
const tensor = imageToTensor(imageData);
|
||||
|
||||
// With ImageNet mean [0.485, 0.456, 0.406] and std [0.229, 0.224, 0.225]
|
||||
// R: (0.502 - 0.485) / 0.229 ≈ 0.074
|
||||
const expectedR = (128 / 255 - 0.485) / 0.229;
|
||||
expect(tensor[0]).toBeCloseTo(expectedR, 3);
|
||||
|
||||
// G: (0.502 - 0.456) / 0.224 ≈ 0.205
|
||||
const totalPixels = 224 * 224;
|
||||
const expectedG = (128 / 255 - 0.456) / 0.224;
|
||||
expect(tensor[totalPixels]).toBeCloseTo(expectedG, 3);
|
||||
|
||||
// B: (0.502 - 0.406) / 0.225 ≈ 0.427
|
||||
const expectedB = (128 / 255 - 0.406) / 0.225;
|
||||
expect(tensor[2 * totalPixels]).toBeCloseTo(expectedB, 3);
|
||||
});
|
||||
|
||||
it("preserves channel separation (R, G, B in separate channels)", () => {
|
||||
// R=255, G=0, B=0 (pure red)
|
||||
const imageData = createMockImageData(224, 224, 255, 0, 0);
|
||||
const tensor = imageToTensor(imageData);
|
||||
const totalPixels = 224 * 224;
|
||||
|
||||
// After ImageNet normalization:
|
||||
// R: (1.0 - 0.485) / 0.229 ≈ 2.25
|
||||
// G: (0.0 - 0.456) / 0.224 ≈ -2.04
|
||||
// B: (0.0 - 0.406) / 0.225 ≈ -1.80
|
||||
const rVal = tensor[0];
|
||||
const gVal = tensor[totalPixels];
|
||||
const bVal = tensor[2 * totalPixels];
|
||||
|
||||
// R is positive (high), G and B are negative (low)
|
||||
expect(rVal).toBeGreaterThan(2);
|
||||
expect(gVal).toBeLessThan(-1);
|
||||
expect(bVal).toBeLessThan(-1);
|
||||
// R is highest
|
||||
expect(rVal).toBeGreaterThan(bVal);
|
||||
expect(rVal).toBeGreaterThan(gVal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tensorToBase64 / base64ToTensor", () => {
|
||||
it("round-trips tensor data correctly", () => {
|
||||
const imageData = createMockImageData(224, 224, 100, 150, 200);
|
||||
const original = imageToTensor(imageData);
|
||||
|
||||
const base64 = tensorToBase64(original);
|
||||
const decoded = base64ToTensor(base64);
|
||||
|
||||
expect(decoded.tensor.length).toBe(original.length);
|
||||
expect(decoded.shape).toEqual([3, 224, 224]);
|
||||
|
||||
// Check a few values match
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(decoded.tensor[i]).toBeCloseTo(original[i], 5);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves custom shape", () => {
|
||||
const tensor = new Float32Array(3 * 299 * 299);
|
||||
const base64 = tensorToBase64(tensor, [3, 299, 299]);
|
||||
const decoded = base64ToTensor(base64);
|
||||
expect(decoded.shape).toEqual([3, 299, 299]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTensorShape", () => {
|
||||
it("returns [1, 3, 224, 224] by default", () => {
|
||||
const shape = getTensorShape();
|
||||
expect(shape).toEqual([1, 3, 224, 224]);
|
||||
});
|
||||
|
||||
it("returns NCHW layout", () => {
|
||||
const shape = getTensorShape();
|
||||
expect(shape.length).toBe(4);
|
||||
expect(shape[0]).toBe(1); // batch
|
||||
expect(shape[1]).toBe(3); // channels
|
||||
expect(shape[2]).toBe(224); // height
|
||||
expect(shape[3]).toBe(224); // width
|
||||
});
|
||||
});
|
||||
|
||||
describe("resizeImage", () => {
|
||||
it("is an async function", () => {
|
||||
expect(typeof resizeImage).toBe("function");
|
||||
});
|
||||
|
||||
it("accepts a File and size parameter", () => {
|
||||
const file = createMockFile({ name: "test.jpg", type: "image/jpeg", size: 1024 });
|
||||
// In jsdom, this will use the mock canvas — we verify the function signature
|
||||
expect(resizeImage).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("constants", () => {
|
||||
it("MAX_FILE_SIZE is 10 MB", () => {
|
||||
expect(MAX_FILE_SIZE).toBe(10 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("MIN_DIMENSION is 150", () => {
|
||||
expect(MIN_DIMENSION).toBe(150);
|
||||
});
|
||||
|
||||
it("ALLOWED_MIME_TYPES includes PNG, JPEG, and WebP", () => {
|
||||
expect(ALLOWED_MIME_TYPES).toContain("image/png");
|
||||
expect(ALLOWED_MIME_TYPES).toContain("image/jpeg");
|
||||
expect(ALLOWED_MIME_TYPES).toContain("image/webp");
|
||||
});
|
||||
});
|
||||
257
apps/web/src/lib/image-processing.ts
Normal file
257
apps/web/src/lib/image-processing.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Client-side image preprocessing pipeline.
|
||||
*
|
||||
* Resizes images to model-expected dimensions (224×224 by default),
|
||||
* converts RGBA → RGB, normalizes pixel values, and produces flat
|
||||
* Float32Array tensors ready for ML inference or base64 transmission.
|
||||
*
|
||||
* Tensor shape: [1, 3, 224, 224] — NCHW layout matching MobileNet / ResNet.
|
||||
*
|
||||
* Configurable via env:
|
||||
* IMAGE_MODEL_SIZE — target dimension (default 224)
|
||||
* IMAGE_MEAN_R/G/B — per-channel mean for normalization (default 0.485, 0.456, 0.406 — ImageNet)
|
||||
* IMAGE_STD_R/G/B — per-channel std for normalization (default 0.229, 0.224, 0.225 — ImageNet)
|
||||
*/
|
||||
|
||||
// ─── Configuration ───────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_MODEL_SIZE = 224;
|
||||
const DEFAULT_MEAN = [0.485, 0.456, 0.406] as const; // ImageNet RGB means
|
||||
const DEFAULT_STD = [0.229, 0.224, 0.225] as const; // ImageNet RGB stds
|
||||
|
||||
function getConfig(): {
|
||||
size: number;
|
||||
mean: readonly [number, number, number];
|
||||
std: readonly [number, number, number];
|
||||
} {
|
||||
// These env vars are exposed via next.config.ts / .env.local
|
||||
const size = parseInt(
|
||||
typeof process !== "undefined" && process.env?.IMAGE_MODEL_SIZE
|
||||
? process.env.IMAGE_MODEL_SIZE
|
||||
: String(DEFAULT_MODEL_SIZE),
|
||||
10,
|
||||
);
|
||||
|
||||
return {
|
||||
size: isNaN(size) ? DEFAULT_MODEL_SIZE : size,
|
||||
mean: DEFAULT_MEAN,
|
||||
std: DEFAULT_STD,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Maximum file size accepted (10 MB) */
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
/** Minimum image dimensions (150×150) */
|
||||
export const MIN_DIMENSION = 150;
|
||||
|
||||
/** Allowed MIME types */
|
||||
export const ALLOWED_MIME_TYPES = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/webp",
|
||||
] as const;
|
||||
|
||||
export type AllowedMimeType = (typeof ALLOWED_MIME_TYPES)[number];
|
||||
|
||||
/** Maximum number of ephemeral uploads to keep */
|
||||
export const MAX_UPLOADS = 100;
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate that a file is an acceptable image for upload.
|
||||
* Returns `{ ok: true }` or `{ ok: false, error: string }`.
|
||||
*/
|
||||
export function validateImageFile(file: File):
|
||||
| { ok: true }
|
||||
| { ok: false; error: string } {
|
||||
// MIME type check
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.type as AllowedMimeType)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unsupported file type "${file.type}". Allowed: PNG, JPG, WebP.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Size check
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
const mb = (file.size / (1024 * 1024)).toFixed(1);
|
||||
return { ok: false, error: `File too large (${mb} MB). Maximum is 10 MB.` };
|
||||
}
|
||||
|
||||
// Zero-size check
|
||||
if (file.size === 0) {
|
||||
return { ok: false, error: "File is empty." };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that an image file meets minimum dimension requirements.
|
||||
* Returns a promise resolving to `{ ok: true }` or `{ ok: false, error: string }`.
|
||||
*/
|
||||
export function validateImageDimensions(
|
||||
file: File,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (img.width < MIN_DIMENSION || img.height < MIN_DIMENSION) {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: `Image too small (${img.width}×${img.height}). Minimum is ${MIN_DIMENSION}×${MIN_DIMENSION}.`,
|
||||
});
|
||||
} else {
|
||||
resolve({ ok: true });
|
||||
}
|
||||
};
|
||||
img.onerror = () => {
|
||||
resolve({ ok: false, error: "Failed to read image dimensions." });
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Resize ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resize an image file to the target model size using an offscreen canvas.
|
||||
* Uses bilinear interpolation via canvas drawing.
|
||||
*
|
||||
* @param file - Source image file
|
||||
* @param size - Target dimension (square). Defaults to IMAGE_MODEL_SIZE env or 224.
|
||||
* @returns ImageData at exactly `size × size`
|
||||
*/
|
||||
export async function resizeImage(
|
||||
file: File,
|
||||
size: number = getConfig().size,
|
||||
): Promise<ImageData> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("Could not get canvas 2D context."));
|
||||
return;
|
||||
}
|
||||
// Bilinear resize via drawImage
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
const imageData = ctx.getImageData(0, 0, size, size);
|
||||
URL.revokeObjectURL(img.src);
|
||||
resolve(imageData);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(img.src);
|
||||
reject(new Error("Failed to load image for resizing."));
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tensor Conversion ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert ImageData (RGBA) to a flat Float32Array tensor in RGB layout.
|
||||
* Drops the alpha channel, normalizes pixel values to [0, 1].
|
||||
*
|
||||
* Output layout: flat array of length 3 × width × height.
|
||||
* Channel order: RRR...GGG...BBB... (channel-first, like PyTorch NCHW without batch dim).
|
||||
*
|
||||
* @param imageData - Source ImageData from resizeImage()
|
||||
* @returns Float32Array of length 3 × size × size with values in [0, 1]
|
||||
*/
|
||||
export function imageToTensor(imageData: ImageData): Float32Array {
|
||||
const { width, height, data } = imageData;
|
||||
const totalPixels = width * height;
|
||||
const config = getConfig();
|
||||
const { mean, std } = config;
|
||||
|
||||
// Allocate channel-first tensor: [3, H, W]
|
||||
const tensor = new Float32Array(3 * totalPixels);
|
||||
|
||||
// Extract R, G, B channels (skip alpha)
|
||||
const rChannel = tensor.subarray(0, totalPixels);
|
||||
const gChannel = tensor.subarray(totalPixels, 2 * totalPixels);
|
||||
const bChannel = tensor.subarray(2 * totalPixels, 3 * totalPixels);
|
||||
|
||||
for (let i = 0; i < totalPixels; i++) {
|
||||
const idx = i * 4; // RGBA stride
|
||||
rChannel[i] = data[idx] / 255;
|
||||
gChannel[i] = data[idx + 1] / 255;
|
||||
bChannel[i] = data[idx + 2] / 255;
|
||||
}
|
||||
|
||||
// Normalize with ImageNet mean/std
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const channel =
|
||||
c === 0 ? rChannel : c === 1 ? gChannel : bChannel;
|
||||
const m = mean[c];
|
||||
const s = std[c];
|
||||
for (let i = 0; i < totalPixels; i++) {
|
||||
channel[i] = (channel[i] - m) / s;
|
||||
}
|
||||
}
|
||||
|
||||
return tensor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected tensor shape for the current model configuration.
|
||||
* Returns [batch, channels, height, width] = [1, 3, size, size].
|
||||
*/
|
||||
export function getTensorShape(): [number, number, number, number] {
|
||||
const size = getConfig().size;
|
||||
return [1, 3, size, size];
|
||||
}
|
||||
|
||||
// ─── Base64 Encoding ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Encode a Float32Array tensor to a base64 string for transmission.
|
||||
* Wraps the binary data in a simple JSON envelope with shape metadata.
|
||||
*
|
||||
* @param tensor - Flat Float32Array from imageToTensor()
|
||||
* @param shape - Tensor shape [C, H, W], defaults to [3, size, size]
|
||||
* @returns base64-encoded JSON string
|
||||
*/
|
||||
export function tensorToBase64(
|
||||
tensor: Float32Array,
|
||||
shape: [number, number, number] = [3, getConfig().size, getConfig().size],
|
||||
): string {
|
||||
const envelope = {
|
||||
shape,
|
||||
data: Array.from(tensor),
|
||||
};
|
||||
const json = JSON.stringify(envelope);
|
||||
return btoa(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 tensor string back to a Float32Array.
|
||||
*
|
||||
* @param base64 - Base64 string from tensorToBase64()
|
||||
* @returns { tensor, shape }
|
||||
*/
|
||||
export function base64ToTensor(base64: string): {
|
||||
tensor: Float32Array;
|
||||
shape: [number, number, number];
|
||||
} {
|
||||
const json = atob(base64);
|
||||
const envelope = JSON.parse(json);
|
||||
return {
|
||||
tensor: new Float32Array(envelope.data),
|
||||
shape: envelope.shape as [number, number, number],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
0
apps/web/src/lib/ml/.gitkeep
Normal file
0
apps/web/src/lib/ml/.gitkeep
Normal file
342
apps/web/src/lib/ml/confidence.test.ts
Normal file
342
apps/web/src/lib/ml/confidence.test.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Unit tests for lib/ml/confidence.ts
|
||||
*
|
||||
* Tests:
|
||||
* - softmax([1, 2, 3]) sums to ~1.0
|
||||
* - softmaxFloat32 produces same results as softmax
|
||||
* - calibrateConfidence(0.9) returns label "high"
|
||||
* - calibrateConfidence(0.6) returns label "medium"
|
||||
* - calibrateConfidence(0.3) returns label "low"
|
||||
* - getTopK returns exactly 5 entries sorted descending
|
||||
* - getTopKFloat32 returns exactly 5 entries sorted descending
|
||||
* - filterByConfidence removes predictions below threshold
|
||||
* - Numerically stable softmax handles large logits
|
||||
* - Degenerate softmax (all -Infinity) returns uniform distribution
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
softmax,
|
||||
softmaxFloat32,
|
||||
calibrateConfidence,
|
||||
getConfidenceLabel,
|
||||
getTopK,
|
||||
getTopKFloat32,
|
||||
filterByConfidence,
|
||||
DEFAULT_MIN_CONFIDENCE,
|
||||
} from "@/lib/ml/confidence";
|
||||
|
||||
describe("softmax", () => {
|
||||
it("softmax([1, 2, 3]) sums to ~1.0", () => {
|
||||
const result = softmax([1, 2, 3]);
|
||||
const sum = result.reduce((a, b) => a + b, 0);
|
||||
expect(sum).toBeCloseTo(1.0, 6);
|
||||
});
|
||||
|
||||
it("produces correct probability distribution", () => {
|
||||
const result = softmax([1, 2, 3]);
|
||||
// Higher input → higher probability
|
||||
expect(result[2]).toBeGreaterThan(result[1]);
|
||||
expect(result[1]).toBeGreaterThan(result[0]);
|
||||
// All positive
|
||||
expect(result.every(v => v > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles equal logits uniformly", () => {
|
||||
const result = softmax([1, 1, 1]);
|
||||
expect(result[0]).toBeCloseTo(1 / 3, 6);
|
||||
expect(result[1]).toBeCloseTo(1 / 3, 6);
|
||||
expect(result[2]).toBeCloseTo(1 / 3, 6);
|
||||
});
|
||||
|
||||
it("handles single element", () => {
|
||||
const result = softmax([5]);
|
||||
expect(result).toEqual([1.0]);
|
||||
});
|
||||
|
||||
it("handles large logits without overflow", () => {
|
||||
const result = softmax([1000, 1001, 1002]);
|
||||
const sum = result.reduce((a, b) => a + b, 0);
|
||||
expect(sum).toBeCloseTo(1.0, 6);
|
||||
// The largest should dominate
|
||||
expect(result[2]).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it("handles negative logits", () => {
|
||||
const result = softmax([-3, -2, -1]);
|
||||
const sum = result.reduce((a, b) => a + b, 0);
|
||||
expect(sum).toBeCloseTo(1.0, 6);
|
||||
expect(result.every(v => v > 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("softmaxFloat32", () => {
|
||||
it("produces Float32Array output", () => {
|
||||
const logits = new Float32Array([1, 2, 3]);
|
||||
const result = softmaxFloat32(logits);
|
||||
expect(result).toBeInstanceOf(Float32Array);
|
||||
});
|
||||
|
||||
it("sums to ~1.0", () => {
|
||||
const logits = new Float32Array([1, 2, 3]);
|
||||
const result = softmaxFloat32(logits);
|
||||
const sum = Array.from(result).reduce((a, b) => a + b, 0);
|
||||
expect(sum).toBeCloseTo(1.0, 5);
|
||||
});
|
||||
|
||||
it("matches softmax for same input", () => {
|
||||
const input = [1, 2, 3, 4, 5];
|
||||
const arrayResult = softmax(input);
|
||||
const float32Result = softmaxFloat32(new Float32Array(input));
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
expect(float32Result[i]).toBeCloseTo(arrayResult[i], 5);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles large arrays (95 classes)", () => {
|
||||
const logits = new Float32Array(95);
|
||||
for (let i = 0; i < 95; i++) {
|
||||
logits[i] = i * 0.1 - 4.75; // centered around 0
|
||||
}
|
||||
const result = softmaxFloat32(logits);
|
||||
const sum = Array.from(result).reduce((a, b) => a + b, 0);
|
||||
expect(sum).toBeCloseTo(1.0, 5);
|
||||
expect(result.length).toBe(95);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calibrateConfidence", () => {
|
||||
it("calibrateConfidence(0.9) returns label 'high'", () => {
|
||||
const result = calibrateConfidence(0.9);
|
||||
expect(result.label).toBe("high");
|
||||
expect(result.raw).toBe(0.9);
|
||||
expect(result.adjusted).toBeGreaterThan(0.8);
|
||||
});
|
||||
|
||||
it("calibrateConfidence(0.95) returns label 'high'", () => {
|
||||
const result = calibrateConfidence(0.95);
|
||||
expect(result.label).toBe("high");
|
||||
});
|
||||
|
||||
it("calibrateConfidence(0.8) returns label 'high'", () => {
|
||||
const result = calibrateConfidence(0.8);
|
||||
expect(result.label).toBe("high");
|
||||
});
|
||||
|
||||
it("calibrateConfidence(0.6) returns label 'medium'", () => {
|
||||
const result = calibrateConfidence(0.6);
|
||||
expect(result.label).toBe("medium");
|
||||
expect(result.adjusted).toBeGreaterThanOrEqual(0.5);
|
||||
expect(result.adjusted).toBeLessThan(0.8);
|
||||
});
|
||||
|
||||
it("calibrateConfidence(0.55) returns label 'medium'", () => {
|
||||
const result = calibrateConfidence(0.55);
|
||||
expect(result.label).toBe("medium");
|
||||
});
|
||||
|
||||
it("calibrateConfidence(0.3) returns label 'low'", () => {
|
||||
const result = calibrateConfidence(0.3);
|
||||
expect(result.label).toBe("low");
|
||||
expect(result.adjusted).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
it("calibrateConfidence(0.1) returns label 'low'", () => {
|
||||
const result = calibrateConfidence(0.1);
|
||||
expect(result.label).toBe("low");
|
||||
});
|
||||
|
||||
it("calibrateConfidence(0.0) returns label 'low'", () => {
|
||||
const result = calibrateConfidence(0.0);
|
||||
expect(result.label).toBe("low");
|
||||
});
|
||||
|
||||
it("calibrateConfidence(1.0) returns label 'high'", () => {
|
||||
const result = calibrateConfidence(1.0);
|
||||
expect(result.label).toBe("high");
|
||||
expect(result.adjusted).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
it("adjusted confidence is rounded to 4 decimal places", () => {
|
||||
const result = calibrateConfidence(0.73);
|
||||
const decimalPlaces = result.adjusted.toString().split(".")[1]?.length || 0;
|
||||
expect(decimalPlaces).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("raw confidence is rounded to 4 decimal places", () => {
|
||||
const result = calibrateConfidence(0.73456789);
|
||||
expect(result.raw).toBe(0.7346);
|
||||
});
|
||||
|
||||
it("adjusted confidence is monotonically increasing with raw", () => {
|
||||
const low = calibrateConfidence(0.3);
|
||||
const mid = calibrateConfidence(0.6);
|
||||
const high = calibrateConfidence(0.9);
|
||||
expect(high.adjusted).toBeGreaterThan(mid.adjusted);
|
||||
expect(mid.adjusted).toBeGreaterThan(low.adjusted);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConfidenceLabel", () => {
|
||||
it("returns 'high' for score >= 0.8", () => {
|
||||
expect(getConfidenceLabel(0.8)).toBe("high");
|
||||
expect(getConfidenceLabel(0.85)).toBe("high");
|
||||
expect(getConfidenceLabel(1.0)).toBe("high");
|
||||
});
|
||||
|
||||
it("returns 'medium' for score >= 0.5 and < 0.8", () => {
|
||||
expect(getConfidenceLabel(0.5)).toBe("medium");
|
||||
expect(getConfidenceLabel(0.65)).toBe("medium");
|
||||
expect(getConfidenceLabel(0.79)).toBe("medium");
|
||||
});
|
||||
|
||||
it("returns 'low' for score < 0.5", () => {
|
||||
expect(getConfidenceLabel(0.0)).toBe("low");
|
||||
expect(getConfidenceLabel(0.49)).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTopK", () => {
|
||||
it("returns exactly 5 entries by default", () => {
|
||||
const probs = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
|
||||
const result = getTopK(probs);
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("returns entries sorted by probability descending", () => {
|
||||
const probs = [0.1, 0.5, 0.3, 0.9, 0.2, 0.7, 0.4];
|
||||
const result = getTopK(probs);
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
expect(result[i].probability).toBeGreaterThanOrEqual(result[i + 1].probability);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns correct class indices", () => {
|
||||
const probs = [0.1, 0.5, 0.3, 0.9, 0.2];
|
||||
const result = getTopK(probs, 3);
|
||||
expect(result[0].classIndex).toBe(3); // 0.9
|
||||
expect(result[1].classIndex).toBe(1); // 0.5
|
||||
expect(result[2].classIndex).toBe(2); // 0.3
|
||||
});
|
||||
|
||||
it("respects custom k value", () => {
|
||||
const probs = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
|
||||
const result = getTopK(probs, 3);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("returns all entries when k > array length", () => {
|
||||
const probs = [0.1, 0.2, 0.3];
|
||||
const result = getTopK(probs, 10);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("handles equal probabilities", () => {
|
||||
const probs = [0.3, 0.3, 0.3, 0.1, 0.1];
|
||||
const result = getTopK(probs, 3);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.every(p => p.probability === 0.3)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTopKFloat32", () => {
|
||||
it("returns exactly 5 entries by default", () => {
|
||||
const probs = new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]);
|
||||
const result = getTopKFloat32(probs);
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("returns entries sorted by probability descending", () => {
|
||||
const probs = new Float32Array([0.1, 0.5, 0.3, 0.9, 0.2, 0.7, 0.4]);
|
||||
const result = getTopKFloat32(probs);
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
expect(result[i].probability).toBeGreaterThanOrEqual(result[i + 1].probability);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns correct class indices", () => {
|
||||
const probs = new Float32Array([0.1, 0.5, 0.3, 0.9, 0.2]);
|
||||
const result = getTopKFloat32(probs, 3);
|
||||
expect(result[0].classIndex).toBe(3);
|
||||
expect(result[1].classIndex).toBe(1);
|
||||
expect(result[2].classIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("handles large arrays (95 classes)", () => {
|
||||
const probs = new Float32Array(95);
|
||||
// Set a few high values
|
||||
probs[0] = 0.4;
|
||||
probs[5] = 0.3;
|
||||
probs[10] = 0.2;
|
||||
probs[20] = 0.05;
|
||||
probs[30] = 0.03;
|
||||
// Rest are small
|
||||
for (let i = 0; i < 95; i++) {
|
||||
if (probs[i] === 0) probs[i] = 0.001;
|
||||
}
|
||||
const result = getTopKFloat32(probs, 5);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[0].classIndex).toBe(0);
|
||||
expect(result[0].probability).toBeCloseTo(0.4, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterByConfidence", () => {
|
||||
it("removes predictions below default threshold", () => {
|
||||
const predictions = [
|
||||
{ classIndex: 0, probability: 0.5 },
|
||||
{ classIndex: 1, probability: 0.3 },
|
||||
{ classIndex: 2, probability: 0.1 },
|
||||
{ classIndex: 3, probability: 0.05 },
|
||||
];
|
||||
const result = filterByConfidence(predictions);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].classIndex).toBe(0);
|
||||
expect(result[1].classIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("uses custom threshold", () => {
|
||||
const predictions = [
|
||||
{ classIndex: 0, probability: 0.5 },
|
||||
{ classIndex: 1, probability: 0.3 },
|
||||
{ classIndex: 2, probability: 0.1 },
|
||||
];
|
||||
const result = filterByConfidence(predictions, 0.25);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty array when all below threshold", () => {
|
||||
const predictions = [
|
||||
{ classIndex: 0, probability: 0.1 },
|
||||
{ classIndex: 1, probability: 0.05 },
|
||||
];
|
||||
const result = filterByConfidence(predictions, 0.2);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all predictions when all above threshold", () => {
|
||||
const predictions = [
|
||||
{ classIndex: 0, probability: 0.5 },
|
||||
{ classIndex: 1, probability: 0.3 },
|
||||
];
|
||||
const result = filterByConfidence(predictions, 0.1);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps predictions at exactly the threshold", () => {
|
||||
const predictions = [
|
||||
{ classIndex: 0, probability: 0.15 },
|
||||
{ classIndex: 1, probability: 0.14 },
|
||||
];
|
||||
const result = filterByConfidence(predictions, 0.15);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].classIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_MIN_CONFIDENCE", () => {
|
||||
it("is 0.15", () => {
|
||||
expect(DEFAULT_MIN_CONFIDENCE).toBe(0.15);
|
||||
});
|
||||
});
|
||||
204
apps/web/src/lib/ml/confidence.ts
Normal file
204
apps/web/src/lib/ml/confidence.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Confidence calibration and threshold logic for ML predictions.
|
||||
*
|
||||
* Provides softmax conversion, confidence calibration, and threshold-based
|
||||
* filtering of predictions.
|
||||
*/
|
||||
|
||||
import type { ConfidenceLabel, ConfidenceResult, RawPrediction } from "@/lib/types";
|
||||
|
||||
// ─── Configuration ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Minimum confidence threshold — predictions below this are filtered out */
|
||||
export const DEFAULT_MIN_CONFIDENCE = 0.15;
|
||||
|
||||
/** Confidence label thresholds */
|
||||
const CONFIDENCE_THRESHOLDS = {
|
||||
HIGH: 0.8,
|
||||
MEDIUM: 0.5,
|
||||
} as const;
|
||||
|
||||
// ─── Softmax ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply softmax to a vector of logits, converting them to probabilities.
|
||||
*
|
||||
* Uses numerically stable softmax: subtracts max before exp() to avoid overflow.
|
||||
*
|
||||
* @param logits - Array of raw model output values
|
||||
* @returns Array of probabilities that sum to ~1.0
|
||||
*/
|
||||
export function softmax(logits: number[]): number[] {
|
||||
const maxLogit = Math.max(...logits);
|
||||
const expValues = logits.map((l) => Math.exp(l - maxLogit));
|
||||
const sumExp = expValues.reduce((a, b) => a + b, 0);
|
||||
|
||||
if (sumExp === 0) {
|
||||
// Degenerate case: all logits are -Infinity
|
||||
const uniform = 1 / logits.length;
|
||||
return logits.map(() => uniform);
|
||||
}
|
||||
|
||||
return expValues.map((e) => e / sumExp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply softmax to a Float32Array of logits.
|
||||
*
|
||||
* @param logits - Float32Array of raw model output values
|
||||
* @returns Float32Array of probabilities that sum to ~1.0
|
||||
*/
|
||||
export function softmaxFloat32(logits: Float32Array): Float32Array {
|
||||
const maxLogit = -Infinity;
|
||||
let actualMax = maxLogit;
|
||||
for (let i = 0; i < logits.length; i++) {
|
||||
if (logits[i] > actualMax) actualMax = logits[i];
|
||||
}
|
||||
|
||||
const expValues = new Float32Array(logits.length);
|
||||
let sumExp = 0;
|
||||
for (let i = 0; i < logits.length; i++) {
|
||||
expValues[i] = Math.exp(logits[i] - actualMax);
|
||||
sumExp += expValues[i];
|
||||
}
|
||||
|
||||
if (sumExp === 0) {
|
||||
const uniform = 1 / logits.length;
|
||||
return new Float32Array(logits.length).fill(uniform);
|
||||
}
|
||||
|
||||
for (let i = 0; i < expValues.length; i++) {
|
||||
expValues[i] /= sumExp;
|
||||
}
|
||||
|
||||
return expValues;
|
||||
}
|
||||
|
||||
// ─── Confidence Calibration ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calibrate a raw probability into an adjusted confidence score with a label.
|
||||
*
|
||||
* Applies a mild calibration that slightly adjusts raw softmax probabilities
|
||||
* to account for model overconfidence. Uses a linear calibration:
|
||||
* adjusted = rawProb * calibrationFactor
|
||||
* where calibrationFactor ≈ 1.0 (default 1.02) to slightly boost
|
||||
* well-separated predictions while keeping the value in [0, 1].
|
||||
*
|
||||
* The calibrated value is clamped to [0, 1] and labeled using thresholds:
|
||||
* high ≥ 0.8
|
||||
* medium ≥ 0.5
|
||||
* low < 0.5
|
||||
*
|
||||
* @param rawProb - Raw softmax probability (0–1)
|
||||
* @param calibrationFactor - Linear calibration factor (default 1.02)
|
||||
* @returns { adjusted, label }
|
||||
*/
|
||||
export function calibrateConfidence(
|
||||
rawProb: number,
|
||||
calibrationFactor = 1.02,
|
||||
): ConfidenceResult {
|
||||
const adjusted = Math.min(1, Math.max(0, rawProb * calibrationFactor));
|
||||
const label = getConfidenceLabel(adjusted);
|
||||
|
||||
return {
|
||||
raw: roundToDecimals(rawProb, 4),
|
||||
adjusted: roundToDecimals(adjusted, 4),
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the confidence label for a given score.
|
||||
*
|
||||
* Thresholds:
|
||||
* high ≥ 0.8
|
||||
* medium ≥ 0.5
|
||||
* low < 0.5
|
||||
*
|
||||
* @param score - Confidence score (0–1)
|
||||
* @returns Confidence label
|
||||
*/
|
||||
export function getConfidenceLabel(score: number): ConfidenceLabel {
|
||||
if (score >= CONFIDENCE_THRESHOLDS.HIGH) return "high";
|
||||
if (score >= CONFIDENCE_THRESHOLDS.MEDIUM) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply sigmoid function: 1 / (1 + exp(-x))
|
||||
*/
|
||||
function sigmoid(x: number): number {
|
||||
return 1 / (1 + Math.exp(-x));
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a number to a given number of decimal places.
|
||||
*/
|
||||
function roundToDecimals(value: number, decimals: number): number {
|
||||
const factor = Math.pow(10, decimals);
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
// ─── Top-K Extraction ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the top-K predictions from a probability array.
|
||||
*
|
||||
* @param probabilities - Array of probabilities (from softmax)
|
||||
* @param k - Number of top predictions to return (default 5)
|
||||
* @returns Array of { classIndex, probability } sorted by probability descending
|
||||
*/
|
||||
export function getTopK(
|
||||
probabilities: number[],
|
||||
k = 5,
|
||||
): RawPrediction[] {
|
||||
// Create indexed pairs
|
||||
const indexed = probabilities.map((prob, index) => ({
|
||||
classIndex: index,
|
||||
probability: prob,
|
||||
}));
|
||||
|
||||
// Sort by probability descending
|
||||
indexed.sort((a, b) => b.probability - a.probability);
|
||||
|
||||
// Take top K
|
||||
return indexed.slice(0, k);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract top-K predictions from a Float32Array of probabilities.
|
||||
*
|
||||
* @param probabilities - Float32Array of probabilities
|
||||
* @param k - Number of top predictions (default 5)
|
||||
* @returns Array of { classIndex, probability } sorted descending
|
||||
*/
|
||||
export function getTopKFloat32(
|
||||
probabilities: Float32Array,
|
||||
k = 5,
|
||||
): RawPrediction[] {
|
||||
const indexed: Array<{ classIndex: number; probability: number }> = [];
|
||||
for (let i = 0; i < probabilities.length; i++) {
|
||||
indexed.push({ classIndex: i, probability: probabilities[i] });
|
||||
}
|
||||
|
||||
indexed.sort((a, b) => b.probability - a.probability);
|
||||
|
||||
return indexed.slice(0, k);
|
||||
}
|
||||
|
||||
// ─── Filtering ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filter predictions by minimum confidence threshold.
|
||||
*
|
||||
* @param predictions - Raw predictions from getTopK()
|
||||
* @param minConfidence - Minimum probability threshold (default 0.15)
|
||||
* @returns Filtered predictions array
|
||||
*/
|
||||
export function filterByConfidence(
|
||||
predictions: RawPrediction[],
|
||||
minConfidence = DEFAULT_MIN_CONFIDENCE,
|
||||
): RawPrediction[] {
|
||||
return predictions.filter((p) => p.probability >= minConfidence);
|
||||
}
|
||||
244
apps/web/src/lib/ml/inference.test.ts
Normal file
244
apps/web/src/lib/ml/inference.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Unit tests for lib/ml/inference.ts
|
||||
*
|
||||
* Tests:
|
||||
* - validateInput rejects non-Float32Array
|
||||
* - validateInput rejects wrong-length arrays
|
||||
* - validateInput rejects NaN/Infinity values
|
||||
* - validateInput accepts correct tensor
|
||||
* - createZeroTensor produces correct shape
|
||||
* - createRandomTensor produces correct shape with finite values
|
||||
* - runInference returns InferenceResult with predictions array
|
||||
* - runInference returns exactly top-K predictions
|
||||
* - runInference predictions are sorted descending
|
||||
* - runInference includes inferenceTimeMs
|
||||
* - runInference completes under 3 seconds
|
||||
* - runBatchInference processes multiple images
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
runInference,
|
||||
validateInput,
|
||||
createZeroTensor,
|
||||
createRandomTensor,
|
||||
runBatchInference,
|
||||
INPUT_SIZE,
|
||||
INPUT_SHAPE,
|
||||
DEFAULT_TOP_K,
|
||||
} from "@/lib/ml/inference";
|
||||
import { resetModelCache } from "@/lib/ml/model-loader";
|
||||
|
||||
describe("validateInput", () => {
|
||||
it("rejects non-Float32Array", () => {
|
||||
expect(() => validateInput([1, 2, 3] as any)).toThrow("Expected Float32Array input");
|
||||
});
|
||||
|
||||
it("rejects wrong-length arrays", () => {
|
||||
const tensor = new Float32Array(100);
|
||||
expect(() => validateInput(tensor)).toThrow(`Expected tensor of length ${INPUT_SIZE}`);
|
||||
});
|
||||
|
||||
it("rejects NaN values", () => {
|
||||
const tensor = new Float32Array(INPUT_SIZE);
|
||||
tensor[50] = NaN;
|
||||
expect(() => validateInput(tensor)).toThrow("non-finite value");
|
||||
});
|
||||
|
||||
it("rejects Infinity values", () => {
|
||||
const tensor = new Float32Array(INPUT_SIZE);
|
||||
tensor[50] = Infinity;
|
||||
expect(() => validateInput(tensor)).toThrow("non-finite value");
|
||||
});
|
||||
|
||||
it("rejects -Infinity values", () => {
|
||||
const tensor = new Float32Array(INPUT_SIZE);
|
||||
tensor[50] = -Infinity;
|
||||
expect(() => validateInput(tensor)).toThrow("non-finite value");
|
||||
});
|
||||
|
||||
it("accepts correct tensor", () => {
|
||||
const tensor = createZeroTensor();
|
||||
expect(() => validateInput(tensor)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts tensor with negative values", () => {
|
||||
const tensor = new Float32Array(INPUT_SIZE);
|
||||
for (let i = 0; i < INPUT_SIZE; i++) {
|
||||
tensor[i] = -2;
|
||||
}
|
||||
expect(() => validateInput(tensor)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts tensor with values near zero", () => {
|
||||
const tensor = new Float32Array(INPUT_SIZE);
|
||||
for (let i = 0; i < INPUT_SIZE; i++) {
|
||||
tensor[i] = 0.0001;
|
||||
}
|
||||
expect(() => validateInput(tensor)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createZeroTensor", () => {
|
||||
it("produces Float32Array", () => {
|
||||
const tensor = createZeroTensor();
|
||||
expect(tensor).toBeInstanceOf(Float32Array);
|
||||
});
|
||||
|
||||
it("has correct length", () => {
|
||||
const tensor = createZeroTensor();
|
||||
expect(tensor.length).toBe(INPUT_SIZE);
|
||||
});
|
||||
|
||||
it("has correct shape dimensions", () => {
|
||||
const expectedLength = INPUT_SHAPE[1] * INPUT_SHAPE[2] * INPUT_SHAPE[3];
|
||||
expect(INPUT_SIZE).toBe(expectedLength);
|
||||
});
|
||||
|
||||
it("all values are zero", () => {
|
||||
const tensor = createZeroTensor();
|
||||
expect(tensor.every(v => v === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRandomTensor", () => {
|
||||
it("produces Float32Array", () => {
|
||||
const tensor = createRandomTensor();
|
||||
expect(tensor).toBeInstanceOf(Float32Array);
|
||||
});
|
||||
|
||||
it("has correct length", () => {
|
||||
const tensor = createRandomTensor();
|
||||
expect(tensor.length).toBe(INPUT_SIZE);
|
||||
});
|
||||
|
||||
it("all values are finite", () => {
|
||||
const tensor = createRandomTensor();
|
||||
expect(tensor.every(v => Number.isFinite(v))).toBe(true);
|
||||
});
|
||||
|
||||
it("produces varied values", () => {
|
||||
const tensor = createRandomTensor();
|
||||
const uniqueValues = new Set(tensor.map(v => v.toFixed(4)));
|
||||
expect(uniqueValues.size).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("passes validateInput", () => {
|
||||
const tensor = createRandomTensor();
|
||||
expect(() => validateInput(tensor)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("INPUT_SHAPE and INPUT_SIZE", () => {
|
||||
it("INPUT_SHAPE is [1, 3, 224, 224]", () => {
|
||||
expect(INPUT_SHAPE).toEqual([1, 3, 224, 224]);
|
||||
});
|
||||
|
||||
it("INPUT_SIZE equals 3 * 224 * 224", () => {
|
||||
expect(INPUT_SIZE).toBe(3 * 224 * 224);
|
||||
});
|
||||
|
||||
it("DEFAULT_TOP_K is 5", () => {
|
||||
expect(DEFAULT_TOP_K).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runInference", () => {
|
||||
beforeEach(() => {
|
||||
resetModelCache();
|
||||
});
|
||||
|
||||
it("returns InferenceResult with predictions array", async () => {
|
||||
const tensor = createRandomTensor();
|
||||
const result = await runInference(tensor);
|
||||
expect(result.predictions).toBeDefined();
|
||||
expect(Array.isArray(result.predictions)).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
it("returns exactly top-K predictions by default", async () => {
|
||||
const tensor = createRandomTensor();
|
||||
const result = await runInference(tensor);
|
||||
expect(result.predictions.length).toBe(DEFAULT_TOP_K);
|
||||
}, 10000);
|
||||
|
||||
it("returns custom top-K predictions", async () => {
|
||||
const tensor = createRandomTensor();
|
||||
const result = await runInference(tensor, 3);
|
||||
expect(result.predictions.length).toBe(3);
|
||||
}, 10000);
|
||||
|
||||
it("predictions are sorted by probability descending", async () => {
|
||||
const tensor = createRandomTensor();
|
||||
const result = await runInference(tensor);
|
||||
for (let i = 0; i < result.predictions.length - 1; i++) {
|
||||
expect(result.predictions[i].probability).toBeGreaterThanOrEqual(
|
||||
result.predictions[i + 1].probability
|
||||
);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it("includes inferenceTimeMs", async () => {
|
||||
const tensor = createRandomTensor();
|
||||
const result = await runInference(tensor);
|
||||
expect(result.inferenceTimeMs).toBeDefined();
|
||||
expect(typeof result.inferenceTimeMs).toBe("number");
|
||||
expect(result.inferenceTimeMs).toBeGreaterThan(0);
|
||||
}, 10000);
|
||||
|
||||
it("completes under 3 seconds", async () => {
|
||||
const tensor = createRandomTensor();
|
||||
const start = performance.now();
|
||||
const result = await runInference(tensor);
|
||||
const elapsed = performance.now() - start;
|
||||
expect(elapsed).toBeLessThan(3000);
|
||||
expect(result.inferenceTimeMs).toBeLessThan(3000);
|
||||
}, 10000);
|
||||
|
||||
it("each prediction has classIndex and probability", async () => {
|
||||
const tensor = createRandomTensor();
|
||||
const result = await runInference(tensor);
|
||||
for (const pred of result.predictions) {
|
||||
expect(pred.classIndex).toBeDefined();
|
||||
expect(typeof pred.classIndex).toBe("number");
|
||||
expect(pred.probability).toBeDefined();
|
||||
expect(typeof pred.probability).toBe("number");
|
||||
expect(pred.probability).toBeGreaterThanOrEqual(0);
|
||||
expect(pred.probability).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it("throws on invalid input", async () => {
|
||||
const badTensor = new Float32Array(100);
|
||||
await expect(runInference(badTensor)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runBatchInference", () => {
|
||||
beforeEach(() => {
|
||||
resetModelCache();
|
||||
});
|
||||
|
||||
it("processes multiple images", async () => {
|
||||
const tensors = [createRandomTensor(), createRandomTensor(), createRandomTensor()];
|
||||
const results = await runBatchInference(tensors);
|
||||
expect(results).toHaveLength(3);
|
||||
for (const result of results) {
|
||||
expect(result.predictions.length).toBe(DEFAULT_TOP_K);
|
||||
expect(result.inferenceTimeMs).toBeGreaterThan(0);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("each result is independent", async () => {
|
||||
const tensors = [createRandomTensor(), createRandomTensor()];
|
||||
const results = await runBatchInference(tensors);
|
||||
// Results should differ (different random inputs → different predictions)
|
||||
expect(results[0].predictions[0].classIndex).toBeDefined();
|
||||
expect(results[1].predictions[0].classIndex).toBeDefined();
|
||||
}, 15000);
|
||||
|
||||
it("accepts custom top-K", async () => {
|
||||
const tensors = [createRandomTensor()];
|
||||
const results = await runBatchInference(tensors, 3);
|
||||
expect(results[0].predictions.length).toBe(3);
|
||||
}, 15000);
|
||||
});
|
||||
141
apps/web/src/lib/ml/inference.ts
Normal file
141
apps/web/src/lib/ml/inference.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* ML inference pipeline for plant disease classification.
|
||||
*
|
||||
* Accepts a preprocessed image tensor, runs it through the model,
|
||||
* applies softmax, extracts top-K predictions, and returns results
|
||||
* with timing metadata.
|
||||
*/
|
||||
|
||||
import type { InferenceResult, RawPrediction } from "@/lib/types";
|
||||
import { getModel } from "./model-loader";
|
||||
import { softmaxFloat32, getTopKFloat32 } from "./confidence";
|
||||
|
||||
// ─── Configuration ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Number of top predictions to return */
|
||||
export const DEFAULT_TOP_K = 5;
|
||||
|
||||
/** Input tensor shape: [batch=1, channels=3, height=224, width=224] */
|
||||
export const INPUT_SHAPE: [number, number, number, number] = [1, 3, 224, 224];
|
||||
|
||||
/** Expected input tensor length */
|
||||
export const INPUT_SIZE = INPUT_SHAPE[1] * INPUT_SHAPE[2] * INPUT_SHAPE[3]; // 3 * 224 * 224 = 150528
|
||||
|
||||
// ─── Main Inference ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run the full inference pipeline on a preprocessed image tensor.
|
||||
*
|
||||
* @param imageTensor - Normalized Float32Array of shape [1, 3, 224, 224] (NCHW)
|
||||
* @param topK - Number of top predictions to return (default 5)
|
||||
* @returns InferenceResult with top-K predictions and timing
|
||||
*/
|
||||
export async function runInference(
|
||||
imageTensor: Float32Array,
|
||||
topK = DEFAULT_TOP_K,
|
||||
): Promise<InferenceResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Validate input
|
||||
validateInput(imageTensor);
|
||||
|
||||
// Get model (lazy loads on first call)
|
||||
const model = await getModel();
|
||||
|
||||
// Run model forward pass
|
||||
const { logits, inferenceTimeMs } = await model.predict(imageTensor);
|
||||
|
||||
// Apply softmax to convert logits to probabilities
|
||||
const probabilities = softmaxFloat32(logits);
|
||||
|
||||
// Extract top-K predictions
|
||||
const predictions = getTopKFloat32(probabilities, topK);
|
||||
|
||||
const totalTime = performance.now() - startTime;
|
||||
|
||||
return {
|
||||
predictions,
|
||||
inferenceTimeMs: Math.round(totalTime),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Input Validation ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate that the input tensor has the expected shape and type.
|
||||
*
|
||||
* @param tensor - Input tensor to validate
|
||||
* @throws Error if tensor is invalid
|
||||
*/
|
||||
export function validateInput(tensor: Float32Array): void {
|
||||
if (!(tensor instanceof Float32Array)) {
|
||||
throw new Error(
|
||||
`Expected Float32Array input, got ${typeof tensor}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (tensor.length !== INPUT_SIZE) {
|
||||
throw new Error(
|
||||
`Expected tensor of length ${INPUT_SIZE} (shape ${INPUT_SHAPE.join("×")}), ` +
|
||||
`got ${tensor.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for NaN/Infinity values
|
||||
for (let i = 0; i < tensor.length; i++) {
|
||||
if (!Number.isFinite(tensor[i])) {
|
||||
throw new Error(
|
||||
`Tensor contains non-finite value at index ${i}: ${tensor[i]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Batch Inference ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run inference on multiple images.
|
||||
*
|
||||
* Currently runs sequentially. For true batching, the model itself would need
|
||||
* to support batch input.
|
||||
*
|
||||
* @param tensors - Array of preprocessed image tensors
|
||||
* @param topK - Number of top predictions per image
|
||||
* @returns Array of inference results
|
||||
*/
|
||||
export async function runBatchInference(
|
||||
tensors: Float32Array[],
|
||||
topK = DEFAULT_TOP_K,
|
||||
): Promise<InferenceResult[]> {
|
||||
const results: InferenceResult[] = [];
|
||||
|
||||
for (const tensor of tensors) {
|
||||
results.push(await runInference(tensor, topK));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Utility ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a zero-filled input tensor for testing.
|
||||
*
|
||||
* @returns Float32Array of shape [1, 3, 224, 224]
|
||||
*/
|
||||
export function createZeroTensor(): Float32Array {
|
||||
return new Float32Array(INPUT_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a random input tensor for testing.
|
||||
*
|
||||
* @returns Float32Array of shape [1, 3, 224, 224] with random values
|
||||
*/
|
||||
export function createRandomTensor(): Float32Array {
|
||||
const tensor = new Float32Array(INPUT_SIZE);
|
||||
for (let i = 0; i < tensor.length; i++) {
|
||||
tensor[i] = (Math.random() * 2 - 1) * 2; // Range roughly -2 to 2
|
||||
}
|
||||
return tensor;
|
||||
}
|
||||
226
apps/web/src/lib/ml/labels.test.ts
Normal file
226
apps/web/src/lib/ml/labels.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Unit tests for lib/ml/labels.ts
|
||||
*
|
||||
* Tests:
|
||||
* - INDEX_TO_DISEASE_ID maps index 0 to "healthy"
|
||||
* - INDEX_TO_DISEASE_ID maps last index to "unknown"
|
||||
* - INDEX_TO_DISEASE_ID maps intermediate indices to disease IDs
|
||||
* - DISEASE_ID_TO_INDEX is inverse of INDEX_TO_DISEASE_ID
|
||||
* - getDiseaseIdForIndex returns "unknown" for out-of-range
|
||||
* - getIndexForDiseaseId returns -1 for unknown ID
|
||||
* - isRealDisease correctly classifies healthy/unknown vs real diseases
|
||||
* - getAllDiseaseIds returns all disease IDs from knowledge base
|
||||
* - NUM_CLASSES equals expected count (diseases + healthy + unknown)
|
||||
* - Bidirectional mapping consistency
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
INDEX_TO_DISEASE_ID,
|
||||
DISEASE_ID_TO_INDEX,
|
||||
getDiseaseIdForIndex,
|
||||
getIndexForDiseaseId,
|
||||
isRealDisease,
|
||||
getAllDiseaseIds,
|
||||
NUM_CLASSES,
|
||||
HEALTHY_INDEX,
|
||||
FIRST_DISEASE_INDEX,
|
||||
UNKNOWN_INDEX,
|
||||
} from "@/lib/ml/labels";
|
||||
import rawDiseases from "@/data/diseases.json";
|
||||
import type { Disease } from "@/lib/types";
|
||||
|
||||
const diseases: Disease[] = rawDiseases as Disease[];
|
||||
|
||||
describe("Constants", () => {
|
||||
it("HEALTHY_INDEX is 0", () => {
|
||||
expect(HEALTHY_INDEX).toBe(0);
|
||||
});
|
||||
|
||||
it("FIRST_DISEASE_INDEX is 1", () => {
|
||||
expect(FIRST_DISEASE_INDEX).toBe(1);
|
||||
});
|
||||
|
||||
it("UNKNOWN_INDEX is 1 + number of diseases", () => {
|
||||
expect(UNKNOWN_INDEX).toBe(1 + diseases.length);
|
||||
});
|
||||
|
||||
it("NUM_CLASSES is UNKNOWN_INDEX + 1", () => {
|
||||
expect(NUM_CLASSES).toBe(UNKNOWN_INDEX + 1);
|
||||
});
|
||||
|
||||
it("NUM_CLASSES equals diseases.length + 2 (healthy + unknown)", () => {
|
||||
expect(NUM_CLASSES).toBe(diseases.length + 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("INDEX_TO_DISEASE_ID", () => {
|
||||
it("maps index 0 to 'healthy'", () => {
|
||||
expect(INDEX_TO_DISEASE_ID[0]).toBe("healthy");
|
||||
});
|
||||
|
||||
it("maps last index to 'unknown'", () => {
|
||||
expect(INDEX_TO_DISEASE_ID[NUM_CLASSES - 1]).toBe("unknown");
|
||||
});
|
||||
|
||||
it("maps intermediate indices to disease IDs", () => {
|
||||
// Index 1 should be the first disease
|
||||
expect(INDEX_TO_DISEASE_ID[1]).toBe(diseases[0].id);
|
||||
// Index 2 should be the second disease
|
||||
expect(INDEX_TO_DISEASE_ID[2]).toBe(diseases[1].id);
|
||||
// Last disease index
|
||||
expect(INDEX_TO_DISEASE_ID[diseases.length]).toBe(diseases[diseases.length - 1].id);
|
||||
});
|
||||
|
||||
it("has exactly NUM_CLASSES entries", () => {
|
||||
const keys = Object.keys(INDEX_TO_DISEASE_ID);
|
||||
expect(keys.length).toBe(NUM_CLASSES);
|
||||
});
|
||||
|
||||
it("all mapped IDs are valid strings", () => {
|
||||
for (const id of Object.values(INDEX_TO_DISEASE_ID)) {
|
||||
expect(typeof id).toBe("string");
|
||||
expect(id.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DISEASE_ID_TO_INDEX", () => {
|
||||
it("maps 'healthy' to index 0", () => {
|
||||
expect(DISEASE_ID_TO_INDEX["healthy"]).toBe(0);
|
||||
});
|
||||
|
||||
it("maps 'unknown' to last index", () => {
|
||||
expect(DISEASE_ID_TO_INDEX["unknown"]).toBe(NUM_CLASSES - 1);
|
||||
});
|
||||
|
||||
it("maps disease IDs to correct indices", () => {
|
||||
for (let i = 0; i < diseases.length; i++) {
|
||||
expect(DISEASE_ID_TO_INDEX[diseases[i].id]).toBe(FIRST_DISEASE_INDEX + i);
|
||||
}
|
||||
});
|
||||
|
||||
it("has exactly NUM_CLASSES entries", () => {
|
||||
const keys = Object.keys(DISEASE_ID_TO_INDEX);
|
||||
expect(keys.length).toBe(NUM_CLASSES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bidirectional mapping", () => {
|
||||
it("INDEX_TO_DISEASE_ID and DISEASE_ID_TO_INDEX are inverses", () => {
|
||||
for (const [idxStr, id] of Object.entries(INDEX_TO_DISEASE_ID)) {
|
||||
const idx = parseInt(idxStr);
|
||||
expect(DISEASE_ID_TO_INDEX[id]).toBe(idx);
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips for all disease IDs", () => {
|
||||
for (const [id, idx] of Object.entries(DISEASE_ID_TO_INDEX)) {
|
||||
expect(INDEX_TO_DISEASE_ID[idx]).toBe(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips for all indices", () => {
|
||||
for (let i = 0; i < NUM_CLASSES; i++) {
|
||||
const id = INDEX_TO_DISEASE_ID[i];
|
||||
expect(DISEASE_ID_TO_INDEX[id]).toBe(i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDiseaseIdForIndex", () => {
|
||||
it("returns 'healthy' for index 0", () => {
|
||||
expect(getDiseaseIdForIndex(0)).toBe("healthy");
|
||||
});
|
||||
|
||||
it("returns disease ID for valid disease index", () => {
|
||||
expect(getDiseaseIdForIndex(1)).toBe(diseases[0].id);
|
||||
});
|
||||
|
||||
it("returns 'unknown' for out-of-range positive index", () => {
|
||||
expect(getDiseaseIdForIndex(1000)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns 'unknown' for negative index", () => {
|
||||
expect(getDiseaseIdForIndex(-1)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns 'unknown' for index past NUM_CLASSES", () => {
|
||||
expect(getDiseaseIdForIndex(NUM_CLASSES + 10)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIndexForDiseaseId", () => {
|
||||
it("returns 0 for 'healthy'", () => {
|
||||
expect(getIndexForDiseaseId("healthy")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns correct index for known disease", () => {
|
||||
const idx = getIndexForDiseaseId(diseases[0].id);
|
||||
expect(idx).toBe(1);
|
||||
});
|
||||
|
||||
it("returns -1 for unknown disease ID", () => {
|
||||
expect(getIndexForDiseaseId("nonexistent-disease")).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns -1 for empty string", () => {
|
||||
expect(getIndexForDiseaseId("")).toBe(-1);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
const lowerIdx = getIndexForDiseaseId(diseases[0].id);
|
||||
const upperIdx = getIndexForDiseaseId(diseases[0].id.toUpperCase());
|
||||
expect(upperIdx).toBe(lowerIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRealDisease", () => {
|
||||
it("returns false for 'healthy'", () => {
|
||||
expect(isRealDisease("healthy")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for 'unknown'", () => {
|
||||
expect(isRealDisease("unknown")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for actual disease IDs", () => {
|
||||
for (const disease of diseases) {
|
||||
expect(isRealDisease(disease.id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns true for arbitrary non-special strings", () => {
|
||||
expect(isRealDisease("some-disease")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllDiseaseIds", () => {
|
||||
it("returns array of all disease IDs", () => {
|
||||
const ids = getAllDiseaseIds();
|
||||
expect(ids.length).toBe(diseases.length);
|
||||
});
|
||||
|
||||
it("excludes 'healthy'", () => {
|
||||
const ids = getAllDiseaseIds();
|
||||
expect(ids).not.toContain("healthy");
|
||||
});
|
||||
|
||||
it("excludes 'unknown'", () => {
|
||||
const ids = getAllDiseaseIds();
|
||||
expect(ids).not.toContain("unknown");
|
||||
});
|
||||
|
||||
it("includes all disease IDs from knowledge base", () => {
|
||||
const ids = getAllDiseaseIds();
|
||||
for (const disease of diseases) {
|
||||
expect(ids).toContain(disease.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("has no duplicates", () => {
|
||||
const ids = getAllDiseaseIds();
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
101
apps/web/src/lib/ml/labels.ts
Normal file
101
apps/web/src/lib/ml/labels.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Class label mapping for the plant disease classifier model.
|
||||
*
|
||||
* Maps model output index → disease ID string.
|
||||
* The model has classes for each disease in the knowledge base,
|
||||
* plus "healthy" and "unknown" catch-all classes.
|
||||
*
|
||||
* Model output shape: [1, NUM_CLASSES] where NUM_CLASSES = 95
|
||||
* (93 diseases + "healthy" + "unknown")
|
||||
*
|
||||
* Index layout:
|
||||
* 0 → "healthy"
|
||||
* 1–93 → disease IDs (order matches diseases.json)
|
||||
* 94 → "unknown"
|
||||
*/
|
||||
|
||||
import rawDiseases from "@/data/diseases.json";
|
||||
import type { Disease } from "@/lib/types";
|
||||
|
||||
const diseases: Disease[] = rawDiseases as Disease[];
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Index for the "healthy" class */
|
||||
export const HEALTHY_INDEX = 0;
|
||||
|
||||
/** First index for actual disease classes */
|
||||
export const FIRST_DISEASE_INDEX = 1;
|
||||
|
||||
/** Index for the "unknown" catch-all class */
|
||||
export const UNKNOWN_INDEX = 1 + diseases.length;
|
||||
|
||||
/** Total number of output classes */
|
||||
export const NUM_CLASSES = UNKNOWN_INDEX + 1;
|
||||
|
||||
// ─── Index → Disease ID mapping ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map from model output index to disease ID string.
|
||||
* Index 0 = "healthy", indices 1..N = disease IDs, last = "unknown".
|
||||
*/
|
||||
export const INDEX_TO_DISEASE_ID: Record<number, string> = Object.freeze(
|
||||
(() => {
|
||||
const map: Record<number, string> = {};
|
||||
map[HEALTHY_INDEX] = "healthy";
|
||||
for (let i = 0; i < diseases.length; i++) {
|
||||
map[FIRST_DISEASE_INDEX + i] = diseases[i].id;
|
||||
}
|
||||
map[UNKNOWN_INDEX] = "unknown";
|
||||
return map;
|
||||
})(),
|
||||
);
|
||||
|
||||
// ─── Disease ID → Index mapping ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map from disease ID string to model output index.
|
||||
*/
|
||||
export const DISEASE_ID_TO_INDEX: Record<string, number> = Object.freeze(
|
||||
(() => {
|
||||
const map: Record<string, number> = {};
|
||||
map["healthy"] = HEALTHY_INDEX;
|
||||
for (let i = 0; i < diseases.length; i++) {
|
||||
map[diseases[i].id] = FIRST_DISEASE_INDEX + i;
|
||||
}
|
||||
map["unknown"] = UNKNOWN_INDEX;
|
||||
return map;
|
||||
})(),
|
||||
);
|
||||
|
||||
// ─── Lookup helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the disease ID for a given model output index.
|
||||
* Returns "unknown" for out-of-range indices.
|
||||
*/
|
||||
export function getDiseaseIdForIndex(index: number): string {
|
||||
return INDEX_TO_DISEASE_ID[index] ?? "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model output index for a given disease ID.
|
||||
* Returns -1 if not found.
|
||||
*/
|
||||
export function getIndexForDiseaseId(diseaseId: string): number {
|
||||
return DISEASE_ID_TO_INDEX[diseaseId.toLowerCase()] ?? -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a disease ID is a real disease (not "healthy" or "unknown").
|
||||
*/
|
||||
export function isRealDisease(diseaseId: string): boolean {
|
||||
return diseaseId !== "healthy" && diseaseId !== "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all known disease IDs (excluding "healthy" and "unknown").
|
||||
*/
|
||||
export function getAllDiseaseIds(): string[] {
|
||||
return diseases.map((d) => d.id);
|
||||
}
|
||||
378
apps/web/src/lib/ml/model-loader.ts
Normal file
378
apps/web/src/lib/ml/model-loader.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Singleton model loader for the plant disease classifier.
|
||||
*
|
||||
* Lazy-loads the TF.js or ONNX model on first call and caches it in memory
|
||||
* via globalThis for subsequent requests. Supports graceful fallback to
|
||||
* mock mode when no model file is present.
|
||||
*
|
||||
* Model files expected at: public/models/plant-disease-classifier/model.json
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
import path from "path";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Model runtime backend */
|
||||
export type ModelBackend = "tfjs" | "onnx" | "mock";
|
||||
|
||||
/** Model loading status */
|
||||
export interface ModelStatus {
|
||||
/** Whether a real model is loaded */
|
||||
loaded: boolean;
|
||||
/** Backend being used */
|
||||
backend: ModelBackend;
|
||||
/** Model identifier string */
|
||||
modelId: string;
|
||||
/** Number of output classes */
|
||||
numClasses: number;
|
||||
/** Error message if loading failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Result from running the model on input data */
|
||||
export interface ModelOutput {
|
||||
/** Raw logits or probabilities from the model */
|
||||
logits: Float32Array;
|
||||
/** Inference time in milliseconds */
|
||||
inferenceTimeMs: number;
|
||||
}
|
||||
|
||||
/** Model interface abstracted over TF.js / ONNX / mock */
|
||||
export interface PlantDiseaseModel {
|
||||
/** Run inference on a preprocessed image tensor */
|
||||
predict(tensor: Float32Array): Promise<ModelOutput>;
|
||||
/** Get model metadata */
|
||||
getStatus(): ModelStatus;
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Path to model files relative to project root */
|
||||
const MODEL_DIR = path.join(process.cwd(), "public", "models", "plant-disease-classifier");
|
||||
const MODEL_JSON_PATH = path.join(MODEL_DIR, "model.json");
|
||||
|
||||
/** Model identifier */
|
||||
export const MODEL_ID = "plant-classifier-v1";
|
||||
|
||||
/** Maximum model load time (ms) */
|
||||
const MODEL_LOAD_TIMEOUT = 30_000;
|
||||
|
||||
// ─── Global cache ────────────────────────────────────────────────────────────
|
||||
|
||||
declare global {
|
||||
var __plantDiseaseModel__: PlantDiseaseModel | undefined;
|
||||
var __plantDiseaseModelLoading__: Promise<PlantDiseaseModel> | undefined;
|
||||
}
|
||||
|
||||
// ─── Model Loader ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the cached model instance, loading it lazily on first call.
|
||||
* Uses globalThis to persist across serverless invocations (within same container).
|
||||
*
|
||||
* @returns Promise resolving to the model (real or mock)
|
||||
*/
|
||||
export async function getModel(): Promise<PlantDiseaseModel> {
|
||||
// Return cached model if available
|
||||
if (globalThis.__plantDiseaseModel__) {
|
||||
return globalThis.__plantDiseaseModel__;
|
||||
}
|
||||
|
||||
// If already loading, wait for the existing promise
|
||||
if (globalThis.__plantDiseaseModelLoading__) {
|
||||
return globalThis.__plantDiseaseModelLoading__;
|
||||
}
|
||||
|
||||
// Start loading
|
||||
const loadingPromise = loadModel();
|
||||
globalThis.__plantDiseaseModelLoading__ = loadingPromise;
|
||||
|
||||
try {
|
||||
const model = await Promise.race([
|
||||
loadingPromise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Model load timed out after ${MODEL_LOAD_TIMEOUT}ms`)), MODEL_LOAD_TIMEOUT),
|
||||
),
|
||||
]);
|
||||
|
||||
globalThis.__plantDiseaseModel__ = model;
|
||||
return model;
|
||||
} finally {
|
||||
globalThis.__plantDiseaseModelLoading__ = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the model, attempting TF.js first, then ONNX, then falling back to mock.
|
||||
*/
|
||||
async function loadModel(): Promise<PlantDiseaseModel> {
|
||||
// Check if model files exist
|
||||
const modelExists = await checkModelFiles();
|
||||
|
||||
if (!modelExists) {
|
||||
console.warn(
|
||||
`[model-loader] Model files not found at ${MODEL_DIR}. Using mock model. ` +
|
||||
`Place TF.js model (model.json + weight shards) in public/models/plant-disease-classifier/`,
|
||||
);
|
||||
return createMockModel();
|
||||
}
|
||||
|
||||
// Try TF.js first
|
||||
try {
|
||||
const tfModel = await tryLoadTFJS();
|
||||
if (tfModel) {
|
||||
console.info(`[model-loader] Loaded TF.js model: ${MODEL_ID}`);
|
||||
return tfModel;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[model-loader] TF.js load failed (${err instanceof Error ? err.message : "unknown"}). Trying ONNX...`,
|
||||
);
|
||||
}
|
||||
|
||||
// Try ONNX Runtime
|
||||
try {
|
||||
const onnxModel = await tryLoadONNX();
|
||||
if (onnxModel) {
|
||||
console.info(`[model-loader] Loaded ONNX model: ${MODEL_ID}`);
|
||||
return onnxModel;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[model-loader] ONNX load failed (${err instanceof Error ? err.message : "unknown"}). Falling back to mock.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fall back to mock
|
||||
console.warn(`[model-loader] All backends failed. Using mock model.`);
|
||||
return createMockModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model files exist on disk.
|
||||
*/
|
||||
async function checkModelFiles(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(MODEL_JSON_PATH);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TensorFlow.js Backend ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Try to load the model using TensorFlow.js.
|
||||
* Attempts @tensorflow/tfjs-node first (server), falls back to @tensorflow/tfjs.
|
||||
*/
|
||||
async function tryLoadTFJS(): Promise<PlantDiseaseModel | null> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let tf: any;
|
||||
|
||||
// Try tfjs-node first (server-side, uses native bindings).
|
||||
// Use dynamic strings so bundlers (Turbopack/webpack) don't trace these
|
||||
// as required dependencies — they are truly optional.
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const tfjsNode = await import("@tensorflow/tfjs-node" + "");
|
||||
tf = tfjsNode;
|
||||
} catch {
|
||||
// Fall back to browser tfjs
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
tf = await import("@tensorflow/tfjs" + "");
|
||||
} catch {
|
||||
return null; // Neither tfjs package available
|
||||
}
|
||||
}
|
||||
|
||||
// Load the model from file path
|
||||
const model = await tf.loadGraphModel(`file://${MODEL_JSON_PATH}`);
|
||||
|
||||
return {
|
||||
async predict(tensor: Float32Array): Promise<ModelOutput> {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Reshape to [1, 3, 224, 224] NCHW → [1, 224, 224, 3] NHWC for TF.js
|
||||
const inputTensor = tf.tensor4d(Array.from(tensor), [3, 224, 224])
|
||||
.transpose([1, 2, 0])
|
||||
.expandDims(0);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const outputTensor = (await model.predict(inputTensor)) as any;
|
||||
const logits = new Float32Array(await outputTensor.data());
|
||||
|
||||
inputTensor.dispose();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
outputTensor.dispose();
|
||||
|
||||
return {
|
||||
logits,
|
||||
inferenceTimeMs: performance.now() - startTime,
|
||||
};
|
||||
},
|
||||
|
||||
getStatus(): ModelStatus {
|
||||
return {
|
||||
loaded: true,
|
||||
backend: "tfjs",
|
||||
modelId: MODEL_ID,
|
||||
numClasses: 95, // Will be updated after model loads
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── ONNX Runtime Backend ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Try to load the model using ONNX Runtime.
|
||||
*/
|
||||
async function tryLoadONNX(): Promise<PlantDiseaseModel | null> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let ort: any;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
ort = await import("onnxruntime-node" + "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look for .onnx file in model directory
|
||||
const onnxPath = path.join(MODEL_DIR, "model.onnx");
|
||||
const onnxExists = fsSync.existsSync(onnxPath);
|
||||
|
||||
if (!onnxExists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await ort.InferenceSession.create(onnxPath);
|
||||
|
||||
return {
|
||||
async predict(tensor: Float32Array): Promise<ModelOutput> {
|
||||
const startTime = performance.now();
|
||||
|
||||
// ONNX expects NCHW format: [1, 3, 224, 224]
|
||||
const inputTensor = new ort.Tensor("float32", tensor, [1, 3, 224, 224]);
|
||||
const feeds = { [session.inputNames[0]]: inputTensor };
|
||||
const results = await session.run(feeds);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const outputValues = Object.values(results) as any[];
|
||||
const logits = new Float32Array(outputValues[0].data);
|
||||
|
||||
inputTensor.dispose();
|
||||
|
||||
return {
|
||||
logits,
|
||||
inferenceTimeMs: performance.now() - startTime,
|
||||
};
|
||||
},
|
||||
|
||||
getStatus(): ModelStatus {
|
||||
return {
|
||||
loaded: true,
|
||||
backend: "onnx",
|
||||
modelId: MODEL_ID,
|
||||
numClasses: 95,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Mock Model ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a deterministic mock model for development/demo mode.
|
||||
*
|
||||
* Generates reproducible predictions based on input tensor hash.
|
||||
* This allows the UI to work without a real model file.
|
||||
*/
|
||||
function createMockModel(): PlantDiseaseModel {
|
||||
return {
|
||||
async predict(tensor: Float32Array): Promise<ModelOutput> {
|
||||
// Simulate inference time (50-200ms)
|
||||
const simulatedTime = 50 + Math.random() * 150;
|
||||
await sleep(simulatedTime);
|
||||
|
||||
// Generate deterministic logits from input hash
|
||||
const logits = generateMockLogits(tensor);
|
||||
|
||||
return {
|
||||
logits,
|
||||
inferenceTimeMs: simulatedTime,
|
||||
};
|
||||
},
|
||||
|
||||
getStatus(): ModelStatus {
|
||||
return {
|
||||
loaded: false,
|
||||
backend: "mock",
|
||||
modelId: MODEL_ID,
|
||||
numClasses: 95,
|
||||
error: "Model files not found. Running in demo mode with mock predictions.",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate deterministic mock logits from input tensor.
|
||||
* Uses a simple hash of the first few tensor values to create
|
||||
* reproducible but varied predictions.
|
||||
*/
|
||||
function generateMockLogits(tensor: Float32Array): Float32Array {
|
||||
const numClasses = 95;
|
||||
const logits = new Float32Array(numClasses);
|
||||
|
||||
// Simple hash of input for deterministic output
|
||||
let hash = 0;
|
||||
const sampleSize = Math.min(100, tensor.length);
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
hash = ((hash << 5) - hash + Math.floor(tensor[i] * 1000)) | 0;
|
||||
}
|
||||
|
||||
// Generate logits using hash as seed
|
||||
// Class 0 (healthy) gets a moderate score
|
||||
logits[0] = (Math.abs(hash % 10) / 10) * 2;
|
||||
|
||||
// Give some disease classes higher scores
|
||||
// This creates a realistic-looking distribution
|
||||
for (let i = 1; i < numClasses - 1; i++) {
|
||||
const seed = ((hash * (i + 1) * 7) % 1000) / 1000;
|
||||
logits[i] = seed * 4 - 1; // Range roughly -1 to 3
|
||||
}
|
||||
|
||||
// Make the top prediction more confident
|
||||
const topIndex = Math.abs(hash % (numClasses - 2)) + 1;
|
||||
logits[topIndex] = 3.5;
|
||||
|
||||
// Second highest
|
||||
const secondIndex = (topIndex + Math.abs(hash % 10) + 1) % (numClasses - 1) + 1;
|
||||
logits[secondIndex] = 2.5;
|
||||
|
||||
logits[numClasses - 1] = -2; // "unknown" gets low score
|
||||
|
||||
return logits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for a given number of milliseconds.
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ─── Reset (for testing) ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reset the model cache. Useful for testing.
|
||||
*/
|
||||
export function resetModelCache(): void {
|
||||
globalThis.__plantDiseaseModel__ = undefined;
|
||||
globalThis.__plantDiseaseModelLoading__ = undefined;
|
||||
}
|
||||
51
apps/web/src/lib/server/image-processing-server.ts
Normal file
51
apps/web/src/lib/server/image-processing-server.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Server-only image processing helpers.
|
||||
*
|
||||
* These functions use Node.js native modules (sharp) and must NOT be
|
||||
* imported by client components. They are used exclusively by API
|
||||
* route handlers (server-side).
|
||||
*/
|
||||
|
||||
// ─── Resize ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Server-side image resize using sharp (if available) or a fallback.
|
||||
* This is used by the upload API route.
|
||||
*
|
||||
* @param buffer - Raw image buffer
|
||||
* @param size - Target dimension
|
||||
* @returns Promise<Buffer> resized image as JPEG
|
||||
*/
|
||||
export async function resizeImageServer(
|
||||
buffer: Buffer,
|
||||
size: number,
|
||||
): Promise<Buffer> {
|
||||
try {
|
||||
const sharpModule = await import("sharp");
|
||||
return sharpModule.default(buffer)
|
||||
.resize(size, size)
|
||||
.jpeg({ quality: 95 })
|
||||
.toBuffer();
|
||||
} catch {
|
||||
// Fallback: return original buffer if sharp is not available
|
||||
// In production, sharp should be installed
|
||||
throw new Error(
|
||||
"sharp is required for server-side image resizing. Install with: npm install sharp",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MIME Type Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a file extension from a MIME type.
|
||||
*/
|
||||
export function mimeTypeToExtension(mimeType: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/webp": "webp",
|
||||
};
|
||||
return map[mimeType] || "jpg";
|
||||
}
|
||||
180
apps/web/src/lib/types.ts
Normal file
180
apps/web/src/lib/types.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Shared TypeScript interfaces for the Plant Disease Knowledge Base.
|
||||
* Used by seed data, API helpers, and API routes.
|
||||
*/
|
||||
|
||||
/** Types of causal agents that cause plant diseases */
|
||||
export type CausalAgentType = "fungal" | "bacterial" | "viral" | "environmental";
|
||||
|
||||
/** Severity levels for plant diseases */
|
||||
export type Severity = "low" | "moderate" | "high" | "critical";
|
||||
|
||||
/** Plant category for grouping and filtering */
|
||||
export type PlantCategory =
|
||||
| "vegetable"
|
||||
| "herb"
|
||||
| "houseplant"
|
||||
| "flower"
|
||||
| "fruit"
|
||||
| "succulent"
|
||||
| "tree";
|
||||
|
||||
/**
|
||||
* A plant entry in the knowledge base.
|
||||
* Each plant has 0+ associated diseases (linked via plantId in Disease).
|
||||
*/
|
||||
export interface Plant {
|
||||
/** Unique identifier (slug), e.g., "tomato", "monstera" */
|
||||
id: string;
|
||||
/** Common name, e.g., "Tomato" */
|
||||
commonName: string;
|
||||
/** Scientific (botanical) name, e.g., "Solanum lycopersicum" */
|
||||
scientificName: string;
|
||||
/** Plant family, e.g., "Solanaceae" */
|
||||
family: string;
|
||||
/** Category for filtering */
|
||||
category: PlantCategory;
|
||||
/** Brief care summary (light, water, humidity, temperature) */
|
||||
careSummary: string;
|
||||
/** URL to a representative image of the healthy plant */
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A disease entry in the knowledge base.
|
||||
* Links to a plant via plantId and can reference lookalike diseases.
|
||||
*/
|
||||
export interface Disease {
|
||||
/** Unique identifier (slug), e.g., "early-blight" */
|
||||
id: string;
|
||||
/** ID of the affected plant */
|
||||
plantId: string;
|
||||
/** Common disease name, e.g., "Early Blight" */
|
||||
name: string;
|
||||
/** Scientific name of the pathogen (if applicable) */
|
||||
scientificName: string;
|
||||
/** Type of causal agent */
|
||||
causalAgentType: CausalAgentType;
|
||||
/** Detailed description of the disease */
|
||||
description: string;
|
||||
/** Observable symptoms (≥3) */
|
||||
symptoms: string[];
|
||||
/** Root causes or contributing factors (≥2) */
|
||||
causes: string[];
|
||||
/** Step-by-step treatment instructions (≥3) */
|
||||
treatment: string[];
|
||||
/** Prevention tips (≥2) */
|
||||
prevention: string[];
|
||||
/** IDs of diseases that look similar and may be confused with this one */
|
||||
lookalikeDiseaseIds: string[];
|
||||
/** Overall severity of the disease */
|
||||
severity: Severity;
|
||||
}
|
||||
|
||||
/** Query parameters for listing/searching plants */
|
||||
export interface PlantListParams {
|
||||
search?: string;
|
||||
category?: PlantCategory;
|
||||
}
|
||||
|
||||
/** Query parameters for listing/searching diseases */
|
||||
export interface DiseaseListParams {
|
||||
plantId?: string;
|
||||
search?: string;
|
||||
causalAgentType?: CausalAgentType;
|
||||
severity?: Severity;
|
||||
}
|
||||
|
||||
/** Response wrapper for a single plant with its diseases */
|
||||
export interface PlantWithDiseases {
|
||||
plant: Plant;
|
||||
diseases: Disease[];
|
||||
}
|
||||
|
||||
/** Response wrapper for a single disease with its plant */
|
||||
export interface DiseaseWithPlant {
|
||||
disease: Disease;
|
||||
plant: Plant;
|
||||
}
|
||||
|
||||
/** Standard error response shape */
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/** Paginated list response (future-proof, currently returns all) */
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ─── ML / Inference types ────────────────────────────────────────────────────
|
||||
|
||||
/** Confidence label based on calibrated score */
|
||||
export type ConfidenceLabel = "high" | "medium" | "low";
|
||||
|
||||
/** Raw prediction from model inference (before calibration) */
|
||||
export interface RawPrediction {
|
||||
/** Model output class index */
|
||||
classIndex: number;
|
||||
/** Raw softmax probability */
|
||||
probability: number;
|
||||
}
|
||||
|
||||
/** Calibrated confidence score with label */
|
||||
export interface ConfidenceResult {
|
||||
/** Raw softmax probability from model */
|
||||
raw: number;
|
||||
/** Calibrated/adjusted confidence score */
|
||||
adjusted: number;
|
||||
/** Human-readable confidence label */
|
||||
label: ConfidenceLabel;
|
||||
}
|
||||
|
||||
/** A single prediction in the identify API response */
|
||||
export interface PredictionResult {
|
||||
/** Disease ID matching knowledge base */
|
||||
diseaseId: string;
|
||||
/** Full disease object from knowledge base */
|
||||
disease: Disease;
|
||||
/** Calibrated confidence */
|
||||
confidence: ConfidenceResult;
|
||||
/** IDs of lookalike diseases that could be confused with this one */
|
||||
lookalikes: string[];
|
||||
}
|
||||
|
||||
/** Metadata about the inference run */
|
||||
export interface InferenceMetadata {
|
||||
/** Model identifier/version */
|
||||
model: string;
|
||||
/** Inference time in milliseconds */
|
||||
inferenceTimeMs: number;
|
||||
/** Image ID that was analyzed */
|
||||
imageId: string;
|
||||
}
|
||||
|
||||
/** Response from POST /api/identify */
|
||||
export interface IdentifyResponse {
|
||||
/** Ranked predictions */
|
||||
predictions: PredictionResult[];
|
||||
/** Inference metadata */
|
||||
metadata: InferenceMetadata;
|
||||
/** True when running in demo/mock mode (no real model loaded) */
|
||||
demo_mode?: boolean;
|
||||
}
|
||||
|
||||
/** Request body for POST /api/identify */
|
||||
export interface IdentifyRequest {
|
||||
/** Image ID from a previous /api/upload call */
|
||||
imageId: string;
|
||||
}
|
||||
|
||||
/** Result from runInference() */
|
||||
export interface InferenceResult {
|
||||
/** Top-K raw predictions sorted by probability descending */
|
||||
predictions: RawPrediction[];
|
||||
/** Inference time in milliseconds */
|
||||
inferenceTimeMs: number;
|
||||
}
|
||||
9
apps/web/src/test/mocks/onnxruntime-node.ts
Normal file
9
apps/web/src/test/mocks/onnxruntime-node.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Mock for onnxruntime-node.
|
||||
* Used during testing when the real package isn't installed.
|
||||
* The model-loader will fall back to mock mode when this import fails.
|
||||
*/
|
||||
|
||||
// This mock throws on load to simulate the package not being available
|
||||
// The model-loader catches this and falls back to mock mode
|
||||
throw new Error("onnxruntime-node not installed");
|
||||
29
apps/web/src/test/mocks/tfjs-node.ts
Normal file
29
apps/web/src/test/mocks/tfjs-node.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Mock for @tensorflow/tfjs-node.
|
||||
* Used during testing when the real package isn't installed.
|
||||
* The model-loader will fall back to mock mode when this import fails.
|
||||
*
|
||||
* This mock exports a module that throws when loadGraphModel is called,
|
||||
* simulating the package being unavailable.
|
||||
*/
|
||||
|
||||
export const version = "mock";
|
||||
|
||||
export function loadGraphModel() {
|
||||
throw new Error("@tensorflow/tfjs-node not installed");
|
||||
}
|
||||
|
||||
export const tensor4d = () => {
|
||||
throw new Error("@tensorflow/tfjs-node not installed");
|
||||
};
|
||||
|
||||
export const node = {
|
||||
loadGraphModel: loadGraphModel,
|
||||
};
|
||||
|
||||
export default {
|
||||
version,
|
||||
loadGraphModel,
|
||||
tensor4d,
|
||||
node,
|
||||
};
|
||||
21
apps/web/src/test/mocks/tfjs.ts
Normal file
21
apps/web/src/test/mocks/tfjs.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Mock for @tensorflow/tfjs.
|
||||
* Used during testing when the real package isn't installed.
|
||||
* The model-loader will fall back to mock mode when this import fails.
|
||||
*/
|
||||
|
||||
export const version = "mock";
|
||||
|
||||
export function loadGraphModel() {
|
||||
throw new Error("@tensorflow/tfjs not installed");
|
||||
}
|
||||
|
||||
export const tensor4d = () => {
|
||||
throw new Error("@tensorflow/tfjs not installed");
|
||||
};
|
||||
|
||||
export default {
|
||||
version,
|
||||
loadGraphModel,
|
||||
tensor4d,
|
||||
};
|
||||
61
apps/web/src/test/setup.ts
Normal file
61
apps/web/src/test/setup.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Vitest setup file.
|
||||
* Provides Canvas API mock for jsdom environment.
|
||||
* Skips canvas mocking in node environment (used by integration tests).
|
||||
*/
|
||||
|
||||
// Import jest-dom matchers for React component tests
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
export {}; // Make this a module for top-level await
|
||||
|
||||
// Only run Canvas mocks in jsdom environment
|
||||
if (typeof document !== "undefined") {
|
||||
const { JSDOM } = await import("jsdom");
|
||||
|
||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
// Assign Canvas-related globals
|
||||
globalThis.HTMLCanvasElement = dom.window.HTMLCanvasElement as any;
|
||||
globalThis.ImageData = dom.window.ImageData as any;
|
||||
globalThis.Image = dom.window.Image as any;
|
||||
|
||||
// Mock CanvasRenderingContext2D
|
||||
const mockContext = {
|
||||
imageSmoothingEnabled: true,
|
||||
imageSmoothingQuality: "high",
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn().mockReturnValue({
|
||||
width: 224,
|
||||
height: 224,
|
||||
data: new Uint8ClampedArray(224 * 224 * 4),
|
||||
}),
|
||||
fillRect: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock canvas.getContext
|
||||
const originalGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function () {
|
||||
return mockContext as any;
|
||||
};
|
||||
|
||||
// Mock document.createElement for canvas
|
||||
const originalCreateElement = document.createElement;
|
||||
document.createElement = function (tagName: string) {
|
||||
if (tagName.toLowerCase() === "canvas") {
|
||||
const canvas = originalCreateElement.call(document, tagName);
|
||||
(canvas as any).getContext = () => mockContext;
|
||||
(canvas as any).width = 224;
|
||||
(canvas as any).height = 224;
|
||||
return canvas;
|
||||
}
|
||||
return originalCreateElement.call(document, tagName);
|
||||
};
|
||||
|
||||
// Mock URL.createObjectURL / revokeObjectURL
|
||||
globalThis.URL.createObjectURL = vi.fn().mockReturnValue("blob:test-url");
|
||||
globalThis.URL.revokeObjectURL = vi.fn();
|
||||
}
|
||||
1
apps/web/src/test/vitest.d.ts
vendored
Normal file
1
apps/web/src/test/vitest.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vitest" />
|
||||
71
apps/web/tailwind.config.ts
Normal file
71
apps/web/tailwind.config.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
/**
|
||||
* Tailwind CSS v4 uses CSS-based configuration via @theme directives in globals.css.
|
||||
* This file is provided for compatibility with tooling that expects a tailwind.config.ts.
|
||||
*
|
||||
* Active theme tokens are defined in src/app/globals.css under @theme inline { ... }.
|
||||
*
|
||||
* Custom color palettes:
|
||||
* - leaf-green (50–900) — primary brand, healthy plant states
|
||||
* - soil-brown (50–900) — earth tones, secondary surfaces
|
||||
* - warning-amber (50–900) — alerts, disease indicators
|
||||
*
|
||||
* Extended spacing: 72, 80, 96
|
||||
*/
|
||||
const config: Config = {
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
|
||||
},
|
||||
colors: {
|
||||
"leaf-green": {
|
||||
50: "#f0fdf4",
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
},
|
||||
"soil-brown": {
|
||||
50: "#fdf8f6",
|
||||
100: "#f2e8e5",
|
||||
200: "#e6d5ce",
|
||||
300: "#d4b5a9",
|
||||
400: "#c0907e",
|
||||
500: "#a3705a",
|
||||
600: "#8a5a48",
|
||||
700: "#724a3c",
|
||||
800: "#5e3e34",
|
||||
900: "#4d342b",
|
||||
},
|
||||
"warning-amber": {
|
||||
50: "#fffbeb",
|
||||
100: "#fef3c7",
|
||||
200: "#fde68a",
|
||||
300: "#fcd34d",
|
||||
400: "#fbbf24",
|
||||
500: "#f59e0b",
|
||||
600: "#d97706",
|
||||
700: "#b45309",
|
||||
800: "#92400e",
|
||||
900: "#78350f",
|
||||
},
|
||||
},
|
||||
spacing: {
|
||||
"72": "18rem",
|
||||
"80": "20rem",
|
||||
"96": "24rem",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
35
apps/web/tsconfig.json
Normal file
35
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["vitest/globals"],
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
24
apps/web/vercel.json
Normal file
24
apps/web/vercel.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"framework": "nextjs",
|
||||
"functions": {
|
||||
"src/app/api/**/*.ts": {
|
||||
"maxDuration": 30
|
||||
}
|
||||
},
|
||||
"regions": ["iad1"],
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "X-Content-Type-Options",
|
||||
"value": "nosniff"
|
||||
},
|
||||
{
|
||||
"key": "X-Frame-Options",
|
||||
"value": "DENY"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
31
apps/web/vitest.config.ts
Normal file
31
apps/web/vitest.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||
setupFiles: ["src/test/setup.ts"],
|
||||
alias: {
|
||||
"@/*": path.resolve(__dirname, "./src/*"),
|
||||
},
|
||||
// Override environment for API integration tests
|
||||
testTimeout: 30000,
|
||||
// Skip external processing for optional ML dependencies
|
||||
server: {
|
||||
deps: {
|
||||
external: ["@tensorflow/tfjs-node", "onnxruntime-node"],
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
// Mock optional ML backends — model-loader falls back to mock mode
|
||||
"@tensorflow/tfjs-node": path.resolve(__dirname, "src/test/mocks/tfjs-node.ts"),
|
||||
"@tensorflow/tfjs": path.resolve(__dirname, "src/test/mocks/tfjs.ts"),
|
||||
"onnxruntime-node": path.resolve(__dirname, "src/test/mocks/onnxruntime-node.ts"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
# 01. Next.js Project Scaffold, Tailwind CSS, and Directory Structure
|
||||
|
||||
meta:
|
||||
id: hyper-specific-plant-disease-id-01
|
||||
feature: hyper-specific-plant-disease-id
|
||||
priority: P1
|
||||
depends_on: []
|
||||
tags: [infrastructure, setup, config]
|
||||
|
||||
objective:
|
||||
- Scaffold a new Next.js 14+ project with App Router, install Tailwind CSS, and establish the standard directory layout so all subsequent tasks have a clean foundation.
|
||||
|
||||
deliverables:
|
||||
- `/apps/web/` — Next.js project with App Router (`app/` directory)
|
||||
- `tailwind.config.ts` and `globals.css` with custom design tokens
|
||||
- Directory structure for `components/`, `lib/`, `api/`, `data/`, `public/uploads/`, `public/models/`
|
||||
- ESLint + Prettier config
|
||||
- Basic health-check route at `/api/health` returning `{ status: "ok" }`
|
||||
- `vercel.json` (initial baseline) and `.env.local` template
|
||||
|
||||
steps:
|
||||
1. Run `npx create-next-app@latest` with TypeScript, App Router, Tailwind CSS, ESLint, and src/ directory enabled.
|
||||
2. Configure `tailwind.config.ts` with custom colors (leaf-green, soil-brown, warning-amber) and extended spacing scale.
|
||||
3. Create `app/globals.css` with Tailwind directives and base typography styles.
|
||||
4. Create directory structure:
|
||||
- `components/` — reusable UI components
|
||||
- `lib/` — utility functions and shared helpers
|
||||
- `lib/api/` — backend API client wrappers
|
||||
- `lib/ml/` — model loading and inference helpers
|
||||
- `data/` — knowledge base seed JSON files
|
||||
- `public/uploads/` — uploaded image storage (gitignored)
|
||||
- `public/models/` — compiled ML model files (git LFS tracked)
|
||||
5. Set up `app/api/health/route.ts` returning `{ status: "ok", timestamp }`.
|
||||
6. Add `.env.local` with placeholder keys (`NEXT_PUBLIC_MODEL_PATH=/models`).
|
||||
7. Create `vercel.json` with framework preset and function region config.
|
||||
8. Add `.prettierrc` and ensure ESLint extends the Next.js recommended config.
|
||||
9. Verify the app boots with `npm run dev` and `/api/health` responds 200.
|
||||
|
||||
tests:
|
||||
- **Unit:** N/A (scaffold has no logic to unit-test).
|
||||
- **Integration:** Hit `/api/health` → expect 200 + JSON with `status: "ok"`.
|
||||
- **Smoke:** `npm run dev` starts without crash; `npm run build` completes.
|
||||
|
||||
acceptance_criteria:
|
||||
- `npm run dev` boots and serves at localhost:3000.
|
||||
- `GET /api/health` returns 200 `{ status: "ok" }`.
|
||||
- Tailwind custom colors compile and appear in the browser.
|
||||
- All directories listed in deliverables exist.
|
||||
- `npm run build` exits successfully with no errors.
|
||||
|
||||
validation:
|
||||
```bash
|
||||
cd apps/web
|
||||
npm install
|
||||
npm run dev &
|
||||
curl http://localhost:3000/api/health
|
||||
# → {"status":"ok","timestamp":"..."}
|
||||
npm run build
|
||||
# → ✓ Ready in ...ms
|
||||
```
|
||||
|
||||
notes:
|
||||
- Use Next.js 14.2+ (stable App Router).
|
||||
- Do NOT create any page UI yet — that belongs in task 06.
|
||||
- Keep `node_modules` gitignored; add `public/uploads/` to `.gitignore` and `public/models/` tracked via Git LFS.
|
||||
@@ -0,0 +1,66 @@
|
||||
# 02. Plant Disease Knowledge Base Schema, Seed Data, and API Endpoints
|
||||
|
||||
meta:
|
||||
id: hyper-specific-plant-disease-id-02
|
||||
feature: hyper-specific-plant-disease-id
|
||||
priority: P1
|
||||
depends_on: [hyper-specific-plant-disease-id-01]
|
||||
tags: [data, api, database]
|
||||
|
||||
objective:
|
||||
- Define the data schema for plants and diseases, seed the knowledge base with ≥80 plant-disease pairs covering common houseplants and garden crops, and expose read-only API endpoints.
|
||||
|
||||
deliverables:
|
||||
- `data/plants.json` — seed data for 20–30 common plants (id, common name, scientific name, family, care summary, image URL)
|
||||
- `data/diseases.json` — seed data for 80–120 disease entries (id, plantId, name, scientific name, causal agent type [fungal/bacterial/viral/environmental], description, symptoms list, causes list, treatment steps, prevention tips, lookalike disease IDs, severity)
|
||||
- `lib/api/diseases.ts` — typed helpers to query the knowledge base by plant ID, disease ID, or search term
|
||||
- `app/api/plants/route.ts` — `GET /api/plants` (list all), `GET /api/plants?search=` (search)
|
||||
- `app/api/plants/[id]/route.ts` — `GET /api/plants/:id` (single plant with its diseases)
|
||||
- `app/api/diseases/route.ts` — `GET /api/diseases` (list all with optional plantId filter)
|
||||
- `app/api/diseases/[id]/route.ts` — `GET /api/diseases/:id` (single disease with full detail)
|
||||
- `lib/types.ts` — shared TypeScript interfaces for Plant, Disease, and related types
|
||||
|
||||
steps:
|
||||
1. Define TypeScript interfaces in `lib/types.ts`: `Plant`, `Disease`, `CausalAgentType`, `Severity`.
|
||||
2. Research and compile seed data for 20–30 common plants (tomato, basil, rose, monstera, pothos, snake plant, peace lily, orchid, succulent varieties, pepper, cucumber, squash, bean, strawberry, mint, lavender, etc.).
|
||||
3. For each plant, research 2–5 common diseases (e.g., Tomato: early blight, late blight, septoria leaf spot, blossom end rot, powdery mildew).
|
||||
4. Write `data/plants.json` and `data/diseases.json` with realistic, detailed entries — each disease must have ≥3 symptoms, ≥2 causes, ≥3 treatment steps, and ≥2 prevention tips.
|
||||
5. Add `lookalikeDiseaseIds` to entries that are easily confused (e.g., early blight vs. septoria leaf spot).
|
||||
6. Implement `lib/api/diseases.ts` with filter-by-plant, filter-by-id, full-text search (name + description), and lookalike resolution.
|
||||
7. Build `app/api/plants/route.ts`, `app/api/plants/[id]/route.ts`, `app/api/diseases/route.ts`, `app/api/diseases/[id]/route.ts` — all return typed JSON with proper error handling (404 for missing IDs, 400 for invalid queries).
|
||||
8. Add request logging middleware and response caching headers (`Cache-Control: public, max-age=3600`).
|
||||
9. Write a smoke test script that validates all seed data has no missing references.
|
||||
|
||||
tests:
|
||||
- **Unit:** Test `lib/api/diseases.ts` search and filter functions with known data.
|
||||
- **Unit:** Validate every disease entry references a valid plant ID and has valid enum values.
|
||||
- **Integration:** `GET /api/plants` returns 200 with plant array; `GET /api/plants/tomato` returns 200 or 404 for unknown ID.
|
||||
- **Integration:** `GET /api/diseases?plantId=tomato` returns only tomato diseases.
|
||||
- **Integration:** `GET /api/diseases/unknown-id` returns 404 with error message.
|
||||
|
||||
acceptance_criteria:
|
||||
- ≥80 disease entries across ≥20 plants exist.
|
||||
- All API endpoints return correct 200/404/400 responses.
|
||||
- Search endpoint returns matches by common name, scientific name, and description.
|
||||
- Lookalike references are bidirectional and valid.
|
||||
- Seed data passes cross-reference validation (no orphan disease entries).
|
||||
|
||||
validation:
|
||||
```bash
|
||||
curl http://localhost:3000/api/plants | jq '.plants | length'
|
||||
# → ≥20
|
||||
|
||||
curl http://localhost:3000/api/diseases | jq '.diseases | length'
|
||||
# → ≥80
|
||||
|
||||
curl http://localhost:3000/api/plants/tomato | jq '.plant.diseases[0].name'
|
||||
# → e.g., "Early Blight"
|
||||
|
||||
curl http://localhost:3000/api/diseases?search=blight | jq '.diseases | length'
|
||||
# → ≥2
|
||||
```
|
||||
|
||||
notes:
|
||||
- Data is file-based JSON (no external DB) so the app remains simple to deploy on Vercel.
|
||||
- Diseases should include both biotic (fungal, bacterial, viral) and abiotic (nutrient deficiency, overwatering, sunburn) conditions.
|
||||
- Each disease entry should be detailed enough that a gardener can confidently diagnose and act.
|
||||
@@ -0,0 +1,69 @@
|
||||
# 03. Image Upload Component and Preprocessing Pipeline
|
||||
|
||||
meta:
|
||||
id: hyper-specific-plant-disease-id-03
|
||||
feature: hyper-specific-plant-disease-id
|
||||
priority: P1
|
||||
depends_on: [hyper-specific-plant-disease-id-01]
|
||||
tags: [frontend, image-processing, component]
|
||||
|
||||
objective:
|
||||
- Build a drag-and-drop / file-picker image upload component with client-side preview, validation (size, type, dimensions), and a preprocessing pipeline that resizes, normalizes, and formats images for the ML inference engine.
|
||||
|
||||
deliverables:
|
||||
- `components/ImageUpload.tsx` — drop zone + file picker with preview thumbnail, progress indicator, and error messages
|
||||
- `lib/image-processing.ts` — client-side image preprocessing: resize to 224×224, convert to RGB tensor (Float32Array), normalize to model-expected range, return as tensor or base64
|
||||
- `app/api/upload/route.ts` — server endpoint that accepts multipart image, runs preprocessing server-side, and returns `{ imageId, tensorShape, previewUrl }`
|
||||
- `lib/api/upload.ts` — client helper to POST image and get back image metadata
|
||||
|
||||
steps:
|
||||
1. Build `components/ImageUpload.tsx`:
|
||||
- Drag-and-drop zone with dashed border and "drop here" / "click to browse" states.
|
||||
- File picker accept `image/*` (PNG, JPG, WebP) with max 10 MB client-side check.
|
||||
- Preview thumbnail rendered as `<img>` from `URL.createObjectURL()`.
|
||||
- Clear / retry button.
|
||||
- Loading spinner during upload.
|
||||
- Inline error display (wrong type, too large, upload failed).
|
||||
2. Implement `lib/image-processing.ts`:
|
||||
- `resizeImage(file: File, size: number): Promise<ImageData>` — draws to offscreen canvas, bilinear resize to 224×224.
|
||||
- `imageToTensor(imageData: ImageData): Float32Array` — converts RGBA to RGB, normalizes to [0,1] or model-specific range, returns flat Float32Array.
|
||||
- `tensorToBase64(tensor: Float32Array): string` — for transmitting to server.
|
||||
3. Build `app/api/upload/route.ts`:
|
||||
- Accept `multipart/form-data` with field `image`.
|
||||
- Validate MIME type, file size (10 MB limit), and minimum dimensions (150×150).
|
||||
- Save uploaded file to `public/uploads/{uuid}.{ext}`.
|
||||
- Run server-side preprocessing pipeline (same resize + normalize logic).
|
||||
- Return `{ imageId: uuid, tensorShape: [1, 3, 224, 224], previewUrl: "/uploads/{uuid}.{ext}" }`.
|
||||
- Cleanup: ephemeral uploads (keep last 100, purge older via cron or on-demand).
|
||||
4. Wire `ImageUpload` component to call `lib/api/upload.ts` on file selection.
|
||||
5. Add loading skeleton while upload is in progress.
|
||||
6. Test with sample images of various sizes, types, and orientations.
|
||||
|
||||
tests:
|
||||
- **Unit:** `resizeImage()` produces 224×224 output for any input aspect ratio.
|
||||
- **Unit:** `imageToTensor()` output length equals `3 * 224 * 224`.
|
||||
- **Unit:** Normalization produces values in [0, 1] range.
|
||||
- **Integration:** Upload a valid JPG → `POST /api/upload` returns 200 with expected shape.
|
||||
- **Integration:** Upload a 12 MB file → returns 413 or validation error.
|
||||
- **Integration:** Upload a `.txt` file → returns 400 with MIME error.
|
||||
|
||||
acceptance_criteria:
|
||||
- User can drag-and-drop or click to select an image.
|
||||
- Preview thumbnail shows before upload.
|
||||
- Upload progress indicator shows.
|
||||
- Server returns imageId and previewUrl.
|
||||
- Non-image files are rejected with clear message.
|
||||
- Images >10 MB are rejected.
|
||||
- Output tensor has shape `[1, 3, 224, 224]`.
|
||||
|
||||
validation:
|
||||
```bash
|
||||
# Upload a sample plant photo
|
||||
curl -X POST -F "image=@test-assets/tomato-leaf.jpg" http://localhost:3000/api/upload
|
||||
# → {"imageId":"uuid","tensorShape":[1,3,224,224],"previewUrl":"/uploads/..."}
|
||||
```
|
||||
|
||||
notes:
|
||||
- The tensor shape `[1, 3, 224, 224]` matches standard MobileNet / ResNet input expectations.
|
||||
- If the user picks a custom model architecture later, the shape constants in `lib/image-processing.ts` should be configurable via env var.
|
||||
- Uploaded files are ephemeral — stored in `public/uploads/` which is gitignored.
|
||||
100
tasks/hyper-specific-plant-disease-id/04-ml-model-integration.md
Normal file
100
tasks/hyper-specific-plant-disease-id/04-ml-model-integration.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 04. ML Model Loading, Inference Pipeline, and Confidence Scoring
|
||||
|
||||
meta:
|
||||
id: hyper-specific-plant-disease-id-04
|
||||
feature: hyper-specific-plant-disease-id
|
||||
priority: P1
|
||||
depends_on: [hyper-specific-plant-disease-id-02, hyper-specific-plant-disease-id-03]
|
||||
tags: [ml, inference, backend]
|
||||
|
||||
objective:
|
||||
- Integrate a custom TensorFlow.js or ONNX-compatible plant disease classifier model into the Next.js API layer — handle model loading, batched inference, confidence scoring, and result ranking against the knowledge base.
|
||||
|
||||
deliverables:
|
||||
- `lib/ml/model-loader.ts` — singleton model loader that lazy-loads the TF.js/ONNX model and caches it in memory
|
||||
- `lib/ml/inference.ts` — `runInference(imageTensor: Float32Array): Promise<RawPrediction[]>` returning top-K class probabilities
|
||||
- `lib/ml/labels.ts` — class label mapping (model output index → disease ID / "healthy" / "unknown")
|
||||
- `lib/ml/confidence.ts` — softmax + confidence calibration, threshold logic (high ≥0.8, medium ≥0.5, low <0.5)
|
||||
- `app/api/identify/route.ts` — `POST /api/identify` accepting `{ imageId }`, running full pipeline, returning ranked results with knowledge base enrichment
|
||||
- `lib/api/identify.ts` — client helper to call the identify endpoint
|
||||
|
||||
steps:
|
||||
1. Set up model storage and loading:
|
||||
- Place compiled model files (`model.json` + weight shards) in `public/models/plant-disease-classifier/`.
|
||||
- Implement `lib/ml/model-loader.ts` with lazy singleton pattern — loads model on first call, keeps in `globalThis` cache for subsequent calls.
|
||||
- Support both TensorFlow.js (`@tensorflow/tfjs-node` for server, `@tensorflow/tfjs` for client fallback) and ONNX Runtime (`onnxruntime-node`).
|
||||
- Graceful fallback: if no model file found, use a deterministic mock returning "model not loaded" with explanatory message.
|
||||
2. Build `lib/ml/inference.ts`:
|
||||
- Accept normalized Float32Array of shape `[1, 3, 224, 224]`.
|
||||
- Run model forward pass.
|
||||
- Apply softmax to logits.
|
||||
- Return top-5 predictions with class indices and raw probabilities.
|
||||
- Measure inference time and attach to result.
|
||||
3. Implement `lib/ml/labels.ts`:
|
||||
- Map model output index → disease ID string (e.g., `0 → "tomato-early-blight"`, `1 → "tomato-late-blight"`, …).
|
||||
- Include `"healthy"` class for each plant.
|
||||
- Include `"unknown"` as final catch-all class.
|
||||
4. Implement `lib/ml/confidence.ts`:
|
||||
- `calibrateConfidence(rawProb: number): { adjusted: number, label: "high" | "medium" | "low" }`.
|
||||
- Apply threshold logic: only return predictions above `minConfidence` (configurable, default 0.15).
|
||||
5. Build `app/api/identify/route.ts`:
|
||||
- Accept `{ imageId }` in request body.
|
||||
- Load image from `public/uploads/{imageId}` and preprocess (reuse pipeline from task 03).
|
||||
- Run inference.
|
||||
- Look up each top-K disease ID in knowledge base (from task 02) to enrich with name, description, symptoms, treatment.
|
||||
- Enrich with lookalike disease cross-references.
|
||||
- Return:
|
||||
```json
|
||||
{
|
||||
"predictions": [
|
||||
{
|
||||
"diseaseId": "tomato-early-blight",
|
||||
"disease": { /* enriched from knowledge base */ },
|
||||
"confidence": { "raw": 0.87, "adjusted": 0.91, "label": "high" },
|
||||
"lookalikes": ["tomato-septoria-leaf-spot"]
|
||||
}
|
||||
],
|
||||
"metadata": { "model": "plant-classifier-v1", "inferenceTimeMs": 320, "imageId": "..." }
|
||||
}
|
||||
```
|
||||
6. Add `lib/api/identify.ts` — a typed client-side function that `POST`s to `/api/identify` with the imageId and returns the typed response.
|
||||
7. If no model file is present at build/runtime, return a deterministic mock response with a `"demo_mode": true` flag so the UI still works for development.
|
||||
|
||||
tests:
|
||||
- **Unit:** `softmax([1, 2, 3])` sums to ~1.0.
|
||||
- **Unit:** `calibrateConfidence(0.9)` returns label `"high"`.
|
||||
- **Unit:** Top-5 extraction returns exactly 5 entries sorted descending.
|
||||
- **Integration:** `POST /api/identify` with valid imageId returns 200 with predictions array.
|
||||
- **Integration:** `POST /api/identify` with invalid imageId returns 404.
|
||||
- **Integration:** Each prediction's `diseaseId` exists in knowledge base (cross-reference).
|
||||
- **Load:** Inference completes under 3 seconds (Vercel serverless timeout).
|
||||
- Potential issue: serverless functions may have higher GPU latency.
|
||||
- Mitigation: consider using Vercel Serverless GPU or a Node.js function with ONNX Runtime CPU.
|
||||
- For initial deployment, CPU inference with MobileNet-derived model under 5MB is acceptable (<1s on V8).
|
||||
|
||||
acceptance_criteria:
|
||||
- Model loads once and caches for subsequent requests.
|
||||
- Inference returns top-5 predictions with confidence scores.
|
||||
- Each prediction is enriched with full knowledge base data.
|
||||
- Predictions include lookalike cross-references.
|
||||
- Response includes inference timing metadata.
|
||||
- Mock mode works when model file is absent.
|
||||
|
||||
validation:
|
||||
```bash
|
||||
# First upload an image
|
||||
UPLOAD_RESP=$(curl -X POST -F "image=@test-assets/tomato-leaf.jpg" http://localhost:3000/api/upload)
|
||||
IMAGE_ID=$(echo $UPLOAD_RESP | jq -r '.imageId')
|
||||
|
||||
# Then identify
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"imageId\": \"$IMAGE_ID\"}" \
|
||||
http://localhost:3000/api/identify | jq '.predictions[0].disease.name'
|
||||
# → "Early Blight"
|
||||
```
|
||||
|
||||
notes:
|
||||
- A pre-trained MobileNetV2 fine-tuned on PlantVillage + augmented custom data is recommended — it's small (<10 MB), fast on CPU, and reasonably accurate.
|
||||
- The actual model training process is OUT OF SCOPE for this task. This task assumes a trained model file is provided. Seed a placeholder warning if missing.
|
||||
- If TF.js Node binding has issues, fall back to ONNX Runtime which is pure C++ and more stable on Lambda/Vercel.
|
||||
- Consider Vercel's maximum serverless function duration (60s on Pro, 10s on Hobby) — keep model <10 MB and inference <3s.
|
||||
@@ -0,0 +1,90 @@
|
||||
# 05. Results Page with Disease Cards, Symptom Comparison, and Treatment Steps
|
||||
|
||||
meta:
|
||||
id: hyper-specific-plant-disease-id-05
|
||||
feature: hyper-specific-plant-disease-id
|
||||
priority: P1
|
||||
depends_on: [hyper-specific-plant-disease-id-04]
|
||||
tags: [frontend, results-page, ui]
|
||||
|
||||
objective:
|
||||
- Build the main identification results page that displays ranked disease predictions as rich, actionable cards — each showing confidence, symptoms, cause, treatment steps, and lookalike comparisons.
|
||||
|
||||
deliverables:
|
||||
- `app/results/[imageId]/page.tsx` — results page route (takes imageId from URL param)
|
||||
- `components/ResultsDashboard.tsx` — top-level results layout: uploaded image preview + ranked prediction cards
|
||||
- `components/DiseaseCard.tsx` — individual disease result card with expandable sections
|
||||
- `components/ConfidenceBadge.tsx` — color-coded confidence indicator (green=high, amber=medium, red=low)
|
||||
- `components/SymptomChecker.tsx` — visual symptom checklist with severity indicators
|
||||
- `components/TreatmentTimeline.tsx` — ordered treatment steps with urgency tags
|
||||
- `components/LookalikeWarning.tsx` — warning banner when lookalike diseases exist, with side-by-side comparison toggle
|
||||
- `lib/api/identify.ts` — (already created in task 04, extend if needed)
|
||||
|
||||
steps:
|
||||
1. Create `app/results/[imageId]/page.tsx`:
|
||||
- Server Component that fetches identification results via API (or accepts them as passed data from the upload flow).
|
||||
- Layout: side-by-side on desktop (image left, results right), stacked on mobile.
|
||||
- Loading skeleton state while results are computed.
|
||||
- Error state if identification fails.
|
||||
- Empty/unexpected state.
|
||||
2. Build `components/ResultsDashboard.tsx`:
|
||||
- Top section: uploaded image thumbnail with metadata (imageId, upload time).
|
||||
- Sortable/dismissible list of `DiseaseCard` components.
|
||||
- Primary diagnosis highlighted with a prominent blue/green border.
|
||||
- Secondary alternatives shown with equal weight below.
|
||||
3. Build `components/DiseaseCard.tsx`:
|
||||
- Collapsed state: disease name, confidence badge, causal agent type icon, one-sentence summary.
|
||||
- Expanded state: full description, symptom list (from `SymptomChecker`), cause list, treatment timeline, prevention tips.
|
||||
- Smooth expand/collapse animation.
|
||||
- "Was this helpful?" feedback buttons at the bottom (UI only, no backend).
|
||||
4. Build `components/ConfidenceBadge.tsx`:
|
||||
- Pill-shaped badge: green background + checkmark for ≥0.8, amber + warning for ≥0.5, red + exclamation for <0.5.
|
||||
- Shows percentage (e.g., "87% confidence").
|
||||
- Hover tooltip explains confidence interpretation.
|
||||
5. Build `components/SymptomChecker.tsx`:
|
||||
- For the predicted disease, show a list of common symptoms with checkboxes.
|
||||
- User can check which symptoms they observe on their plant.
|
||||
- A match counter shows "3 of 5 symptoms match".
|
||||
- Helps user confirm/reject the diagnosis.
|
||||
6. Build `components/TreatmentTimeline.tsx`:
|
||||
- Ordered card showing immediate, short-term, and long-term treatment steps.
|
||||
- Each step has: action text, urgency badge (immediate/within week/ongoing), optional photo reference link.
|
||||
- "Treatments may vary" disclaimer at bottom.
|
||||
7. Build `components/LookalikeWarning.tsx`:
|
||||
- Yellow banner: "This disease is easily confused with [lookalike name]."
|
||||
- Click to expand side-by-side symptom comparison table.
|
||||
- Comparison table columns: symptom, this disease, lookalike disease.
|
||||
- Links to lookalike disease detail.
|
||||
8. Add `app/results/page.tsx` redirect — if someone navigates directly without an imageId, redirect to homepage.
|
||||
|
||||
tests:
|
||||
- **Unit:** `ConfidenceBadge` renders correct color for high/medium/low thresholds.
|
||||
- **Unit:** `DiseaseCard` expands and collapses on click.
|
||||
- **Unit:** `SymptomChecker` counter updates when toggling checkboxes.
|
||||
- **Integration:** Navigate to `/results/{valid-imageId}` → page renders with ≥1 disease card.
|
||||
- **Integration:** Navigate to `/results/invalid-id` → shows error state.
|
||||
- **Integration:** `LookalikeWarning` appears when prediction includes lookalike field.
|
||||
- **Visual:** All components render correctly at 375px, 768px, and 1280px widths.
|
||||
|
||||
acceptance_criteria:
|
||||
- Results page renders within 1 second after identification API responds.
|
||||
- Primary diagnosis is visually distinguished from alternatives.
|
||||
- Each disease card expands to show full symptoms, causes, and treatment.
|
||||
- Symptom checker lets user confirm/reject diagnosis interactively.
|
||||
- Lookalike warnings appear when applicable with side-by-side comparison.
|
||||
- Confidence badges use correct colors and labels.
|
||||
- Page works on mobile (stacked layout) and desktop (side-by-side).
|
||||
- Loading skeleton and error states are handled.
|
||||
|
||||
validation:
|
||||
- Upload a plant photo → results page loads with disease cards.
|
||||
- Click "expand" on a disease card → symptoms, causes, treatment sections appear.
|
||||
- Toggle symptom checkboxes → match counter updates.
|
||||
- Lookalike warning shows for easily confused diseases.
|
||||
- Resize browser to mobile width → layout stacks vertically.
|
||||
- Navigate to `/results/unknown` → error message with "Try again" button.
|
||||
|
||||
notes:
|
||||
- The results page receives the identification payload either via router params (server-rendered) or client-side fetch after upload.
|
||||
- For Vercel deployment, prefer client-side fetch to avoid serverless function timeouts on long inference.
|
||||
- All text content is server-rendered where possible for SEO on disease information pages.
|
||||
@@ -0,0 +1,103 @@
|
||||
# 06. Responsive UI, Homepage, Navigation, Loading States, and Error Handling
|
||||
|
||||
meta:
|
||||
id: hyper-specific-plant-disease-id-06
|
||||
feature: hyper-specific-plant-disease-id
|
||||
priority: P2
|
||||
depends_on: [hyper-specific-plant-disease-id-01, hyper-specific-plant-disease-id-05]
|
||||
tags: [frontend, ui, ux, polish]
|
||||
|
||||
objective:
|
||||
- Build the public-facing pages (homepage, browse plants, about), global navigation, consistent loading skeletons, error boundaries, and responsive layout so the app feels polished and production-ready.
|
||||
|
||||
deliverables:
|
||||
- `app/page.tsx` — homepage hero with upload CTA, feature highlights, sample disease cards
|
||||
- `app/browse/page.tsx` — browse/search all plants with disease counts
|
||||
- `app/browse/[plantId]/page.tsx` — single plant detail page with its disease list
|
||||
- `app/about/page.tsx` — about page explaining methodology and disclaimer
|
||||
- `components/Navbar.tsx` — responsive global navigation with search bar
|
||||
- `components/Footer.tsx` — site footer with links and disclaimer
|
||||
- `components/LoadingSkeleton.tsx` — reusable skeleton component for loading states
|
||||
- `components/ErrorBoundary.tsx` — React error boundary with fallback UI
|
||||
- `components/EmptyState.tsx` — reusable empty state with illustration and CTA
|
||||
- `app/layout.tsx` — root layout with Navbar, Footer, metadata, and font loading
|
||||
- `lib/constants.ts` — site-wide constants (app name, tagline, social links)
|
||||
|
||||
steps:
|
||||
1. Build `components/Navbar.tsx`:
|
||||
- Sticky top bar with app name/logo, navigation links (Home, Browse Plants, About).
|
||||
- Mobile hamburger menu with slide-out drawer.
|
||||
- Search input that navigates to `/browse?search=...` on submit.
|
||||
- Active link highlighting.
|
||||
2. Build `components/Footer.tsx`:
|
||||
- Three-column layout: about blurb, quick links, disclaimer (beta, not a professional diagnosis).
|
||||
- "Made by gardeners, for gardeners" tagline.
|
||||
3. Build `app/page.tsx` (Homepage):
|
||||
- Hero section: large headline ("Snap. Identify. Treat."), subtitle, prominent image upload component (from task 03) centered.
|
||||
- How-it-works section: 3-step illustration (Upload → AI Analysis → Treatment Plan).
|
||||
- Featured plants / common diseases section — carousel of 6-8 popular plant cards linking to `/browse/...`.
|
||||
- Trust signals: "Trained on 50K+ images", "Covers 20+ plants", "Open source".
|
||||
4. Build `app/browse/page.tsx`:
|
||||
- Grid of plant cards (image + name + disease count).
|
||||
- Search input at top filters plants client-side by name.
|
||||
- Category filter chips: "All", "Vegetables", "Herbs", "Houseplants", "Flowers".
|
||||
- Click a card → navigates to `/browse/{plantId}`.
|
||||
5. Build `app/browse/[plantId]/page.tsx`:
|
||||
- Plant hero: common name, scientific name, family, care summary.
|
||||
- Disease list: each disease is a card linking to... (todo: disease detail page could be a future enhancement; for now show expandable info inline or redirect to the identification flow).
|
||||
- "Identify a disease on this plant" button → triggers upload flow targeting this plant for narrowed identification.
|
||||
6. Build `app/about/page.tsx`:
|
||||
- Mission statement, how the model works (plain language), data sources, limitations disclaimer.
|
||||
- Open-source credits and contribution guide.
|
||||
- FAQ accordion.
|
||||
7. Build `components/LoadingSkeleton.tsx`:
|
||||
- Configurable skeleton: `variant` (card, text, image, circle) and `count` (repeat).
|
||||
- Pulse animation using Tailwind `animate-pulse`.
|
||||
- Export presets: `ResultsSkeleton`, `PlantCardSkeleton`, `UploadSkeleton`.
|
||||
8. Build `components/ErrorBoundary.tsx`:
|
||||
- Class-based React error boundary with `componentDidCatch`.
|
||||
- Fallback UI: friendly message ("Something went wrong!"), error detail (dev mode), "Try again" button, "Go home" link.
|
||||
9. Build `components/EmptyState.tsx`:
|
||||
- Illustration (emoji or SVG), message, optional CTA button.
|
||||
- Used in browse page when search returns no results.
|
||||
10. Update `app/layout.tsx`:
|
||||
- Import and wrap Navbar + Footer.
|
||||
- Set metadata (title template, description, Open Graph).
|
||||
- Load Inter font via Next.js font optimization.
|
||||
- Wrap children in `ErrorBoundary`.
|
||||
11. Add responsive breakpoints and test on 375px, 768px, 1024px, 1440px.
|
||||
12. Add smooth scroll behavior and transition utilities.
|
||||
|
||||
tests:
|
||||
- **Integration:** Homepage loads with hero, upload component, and featured plants.
|
||||
- **Integration:** Browse page renders plant grid and search filters work.
|
||||
- **Integration:** Plant detail page shows disease list.
|
||||
- **Integration:** Mobile hamburger menu opens and closes.
|
||||
- **Integration:** Error boundary catches thrown errors and shows fallback.
|
||||
- **Integration:** Search in navbar navigates to browse page with query param.
|
||||
- **Visual:** All pages render without layout shift at 375px and 1280px.
|
||||
|
||||
acceptance_criteria:
|
||||
- Homepage, browse, plant detail, and about pages all render without errors.
|
||||
- Navigation is accessible via keyboard and screen reader.
|
||||
- Loading skeletons appear while pages/data are loading.
|
||||
- Error boundary catches runtime errors with helpful fallback.
|
||||
- Empty state shown when search or filter yields no results.
|
||||
- Mobile navigation hamburger menu works on touch devices.
|
||||
- All pages pass basic Lighthouse audit (no layout shift, proper heading hierarchy).
|
||||
|
||||
validation:
|
||||
```bash
|
||||
curl http://localhost:3000/ # → 200, homepage renders
|
||||
curl http://localhost:3000/browse # → 200, plant grid renders
|
||||
curl http://localhost:3000/browse/tomato # → 200, tomato detail
|
||||
curl http://localhost:3000/about # → 200, about page
|
||||
# Open in browser at 375px → hamburger menu visible and functional
|
||||
# Run Lighthouse → Performance ≥90, Accessibility ≥90, SEO ≥90
|
||||
```
|
||||
|
||||
notes:
|
||||
- Use Next.js `<Image>` component with remote patterns configured for any external plant photos.
|
||||
- All static pages are server-rendered (no `'use client'` unless interactivity requires it).
|
||||
- The homepage should feel warm and approachable — use plant emoji / botanical illustrations as visual elements.
|
||||
- Keep the beta disclaimer visible in footer to manage expectations (AI is not a substitute for professional diagnosis).
|
||||
@@ -0,0 +1,134 @@
|
||||
# 07. Test Suite, Vercel Deployment Config, and CI Pipeline
|
||||
|
||||
meta:
|
||||
id: hyper-specific-plant-disease-id-07
|
||||
feature: hyper-specific-plant-disease-id
|
||||
priority: P2
|
||||
depends_on: [hyper-specific-plant-disease-id-02, hyper-specific-plant-disease-id-05, hyper-specific-plant-disease-id-06]
|
||||
tags: [testing, deployment, ci, qa]
|
||||
|
||||
objective:
|
||||
- Write comprehensive unit and integration tests for all modules, configure Vercel deployment with proper environment variables and build settings, and set up a GitHub Actions CI pipeline that runs on every push.
|
||||
|
||||
deliverables:
|
||||
- `__tests__/` — organized test directory mirroring source structure
|
||||
- `jest.config.ts` — Jest configuration for Next.js with `@testing-library/react`
|
||||
- `__tests__/lib/api/diseases.test.ts` — knowledge base query tests
|
||||
- `__tests__/lib/image-processing.test.ts` — resize and tensor conversion tests
|
||||
- `__tests__/lib/ml/inference.test.ts` — softmax, confidence calibration, label mapping tests
|
||||
- `__tests__/components/ImageUpload.test.tsx` — upload component interaction tests
|
||||
- `__tests__/components/DiseaseCard.test.tsx` — card expand/collapse and rendering tests
|
||||
- `__tests__/components/ConfidenceBadge.test.tsx` — badge color/label tests
|
||||
- `__tests__/components/Navbar.test.tsx` — navigation and mobile menu tests
|
||||
- `__tests__/components/SymptomChecker.test.tsx` — checklist interaction tests
|
||||
- `__tests__/pages/homepage.test.tsx` — homepage render and element presence
|
||||
- `__tests__/pages/browse.test.tsx` — browse page render and search filter
|
||||
- `__tests__/pages/results.test.tsx` — results page with mock API data
|
||||
- `__tests__/api/health.test.ts` — health endpoint integration test
|
||||
- `__tests__/api/plants.test.ts` — plants API endpoint tests
|
||||
- `__tests__/api/diseases.test.ts` — diseases API endpoint tests
|
||||
- `__tests__/api/upload.test.ts` — upload endpoint with mock file
|
||||
- `__tests__/api/identify.test.ts` — identify endpoint integration test
|
||||
- `.github/workflows/ci.yml` — CI pipeline: lint → test → build
|
||||
- `next.config.js` — final configuration for Vercel deployment
|
||||
- `vercel.json` — deployment config (framework, rewrites, headers, regions)
|
||||
|
||||
steps:
|
||||
1. Initialize Jest with `jest.config.ts`:
|
||||
- Use `next/jest` preset.
|
||||
- Configure `@testing-library/react` and `@testing-library/jest-dom`.
|
||||
- Set up `jest.setup.ts` for global mocks (fetch, canvas, File, etc.).
|
||||
2. Write unit tests for all lib modules:
|
||||
- **Knowledge base queries:** search by plant, search by disease, search by text, 404 handling.
|
||||
- **Image processing:** resize maintains aspect ratio, tensor shape is correct, normalization range is [0,1].
|
||||
- **ML inference:** softmax sums to 1, top-5 extraction, confidence thresholds.
|
||||
3. Write component tests using `@testing-library/react`:
|
||||
- `ImageUpload`: simulate drag/drop, file selection, validation errors, upload progress.
|
||||
- `DiseaseCard`: click expands/collapses, content renders.
|
||||
- `ConfidenceBadge`: correct color for each confidence level.
|
||||
- `SymptomChecker`: toggle checkboxes updates counter.
|
||||
- `Navbar`: links render, mobile menu toggles.
|
||||
- `LoadingSkeleton`: renders correct variant and count.
|
||||
- `ErrorBoundary`: catches error and renders fallback.
|
||||
4. Write integration tests for API routes:
|
||||
- Use `next-test-api-route-handler` or Jest with Vercel's test helpers.
|
||||
- Test each endpoint with valid and invalid inputs.
|
||||
- Test response status codes and body shapes.
|
||||
- Test error paths (404, 400, 413, 500).
|
||||
5. Write page integration tests:
|
||||
- Homepage renders hero, upload CTA, featured plants.
|
||||
- Browse page renders plant grid, search filters work.
|
||||
- Results page renders with mock prediction data.
|
||||
- 404 page shows for unknown routes.
|
||||
6. Create `.github/workflows/ci.yml`:
|
||||
```yaml
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run test -- --coverage
|
||||
- run: npm run build
|
||||
```
|
||||
7. Finalize `next.config.js`:
|
||||
- Enable SWC minification.
|
||||
- Configure `images.remotePatterns` if using external plant photos.
|
||||
- Set `experimental.serverActions` for any server actions.
|
||||
- Add security headers (CSP, X-Frame-Options, etc.).
|
||||
8. Update `vercel.json`:
|
||||
```json
|
||||
{
|
||||
"framework": "nextjs",
|
||||
"regions": ["iad1"],
|
||||
"headers": [
|
||||
{ "source": "/(.*)", "headers": [
|
||||
{ "key": "X-Content-Type-Options", "value": "nosniff" },
|
||||
{ "key": "X-Frame-Options", "value": "DENY" },
|
||||
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
|
||||
]}
|
||||
],
|
||||
"functions": {
|
||||
"api/identify/route.ts": { "memory": 1024, "maxDuration": 30 }
|
||||
}
|
||||
}
|
||||
```
|
||||
9. Add `.env.production` template with production values.
|
||||
10. Run full test suite and ensure 100% pass rate.
|
||||
11. Perform a dry-run Vercel deploy (`vercel --prod --dry-run`) to validate config.
|
||||
|
||||
tests:
|
||||
- **All the test files listed in deliverables above** — each must pass.
|
||||
- **Code coverage:** ≥80% line coverage across lib/, components/, and API routes.
|
||||
- **Lint:** ESLint passes with zero errors.
|
||||
- **Build:** `next build` completes with zero warnings (or documented exceptions).
|
||||
|
||||
acceptance_criteria:
|
||||
- `npm test` passes all tests (unit + integration + component).
|
||||
- `npm run build` completes successfully.
|
||||
- GitHub Actions CI runs and passes on every push.
|
||||
- Vercel preview deployment succeeds.
|
||||
- Code coverage report shows ≥80% across all modules.
|
||||
- All API endpoints tested for both happy and error paths.
|
||||
- Security headers present in Vercel response.
|
||||
|
||||
validation:
|
||||
```bash
|
||||
npm run lint # → No errors
|
||||
npm run test # → All tests passing, coverage report generated
|
||||
npm run build # → ✓ Compiled successfully
|
||||
# CI: push to GitHub → Actions tab shows green checkmark
|
||||
# Vercel: vercel --prod → deployment URL loads homepage
|
||||
```
|
||||
|
||||
notes:
|
||||
- Use `jest-canvas-mock` for canvas-dependent tests (image processing).
|
||||
- Use `next-test-api-route-handler` for API route integration tests without running a full server.
|
||||
- For ML inference tests, mock the model loading and return deterministic fake predictions.
|
||||
- If Vercel Hobby plan's 10s function timeout is too restrictive for the identify endpoint, consider upgrading or using a Vercel Serverless Function with increased limits.
|
||||
- Add a `test-assets/` directory with small sample plant images used across tests.
|
||||
27
tasks/hyper-specific-plant-disease-id/README.md
Normal file
27
tasks/hyper-specific-plant-disease-id/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Hyper-Specific Plant Disease Identification
|
||||
|
||||
Objective: Build a Next.js web application with a custom ML model that lets users upload plant photos and receive hyper-specific disease identification with treatment guidance.
|
||||
|
||||
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 01 — Next.js project scaffold, Tailwind CSS, and directory structure → `01-nextjs-project-scaffold.md`
|
||||
- [x] 02 — Plant disease knowledge base schema, seed data, and API endpoints → `02-plant-disease-knowledge-base.md`
|
||||
- [x] 03 — Image upload component and preprocessing pipeline → `03-image-upload-and-preprocessing.md`
|
||||
- [x] 04 — ML model loading, inference pipeline, and confidence scoring → `04-ml-model-integration.md`
|
||||
- [~] 05 — Results page with disease cards, symptom comparison, and treatment steps → `05-identification-results-page.md`
|
||||
- [x] 06 — Responsive UI, homepage, navigation, loading states, and error handling → `06-user-interface-and-polish.md`
|
||||
- [ ] 07 — Test suite, Vercel deployment config, and CI pipeline → `07-testing-and-deployment.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- 01 → 02, 03, 06
|
||||
- 02 → 04
|
||||
- 03 → 04
|
||||
- 04 → 05
|
||||
- 05, 06 → 07
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- The feature is complete when a user can upload a plant photo and receive a hyper-specific disease diagnosis with confidence score, symptoms, causes, treatment steps, and prevention tips — all on a responsive, mobile-first site deployed to Vercel.
|
||||
Reference in New Issue
Block a user