Yuki / views /HealthCheckView.tsx
Severian's picture
Upload 43 files
be02369 verified
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { useLocalStorage } from '../hooks/useLocalStorage';
import { runHealthCheck, isAIConfigured } from '../services/geminiService';
import type { View, HealthCheckResult, BonsaiTree, DiaryLog } from '../types';
import { AppStatus, LogTag } from '../types';
import { StethoscopeIcon, LeafIcon, DropletIcon, BugIcon, BonsaiIcon, SparklesIcon, AlertTriangleIcon, UploadCloudIcon, CheckCircleIcon, ArrowLeftIcon } from '../components/icons';
import Spinner from '../components/Spinner';
type Stage = 'selecting_problem' | 'uploading_photo' | 'analyzing' | 'results';
const problemCategories = [
{ name: 'Leaf Discoloration', description: 'Yellowing, browning, or strange colors on leaves.', icon: LeafIcon, instruction: "Take a clear, well-lit photo of the discolored leaves. Show both the top and underside if possible." },
{ name: 'Spots or Residue', description: 'Powdery mildew, black spots, or sticky residue.', icon: DropletIcon, instruction: "Get a close-up of the spots or residue. Try to have a healthy leaf in the background for comparison." },
{ name: 'Pests or Damage', description: 'Visible insects, webbing, or chewed leaves.', icon: BugIcon, instruction: "Photograph the pests or the damage they've caused. If the pest is tiny, get as close as you can while maintaining focus." },
{ name: 'Wilting or Drooping', description: 'Leaves or branches are losing turgidity and hanging down.', icon: BonsaiIcon, instruction: "Show the entire wilting branch or section of the tree. Also, include a photo of the soil surface if possible." },
{ name: 'General Weakness', description: 'Overall lack of vigor, poor growth, or branch dieback.', icon: AlertTriangleIcon, instruction: "Take a photo of the entire tree so its overall structure and condition are visible." },
];
const HealthCheckView: React.FC<{ setActiveView: (view: View) => void }> = ({ setActiveView }) => {
const [stage, setStage] = useState<Stage>('selecting_problem');
const [selectedCategory, setSelectedCategory] = useState<(typeof problemCategories)[0] | null>(null);
const [trees, setTrees] = useLocalStorage<BonsaiTree[]>('bonsai-diary-trees', []);
const [selectedTreeId, setSelectedTreeId] = useState<string>('');
const [treeInfo, setTreeInfo] = useState({ species: '', location: '' });
const [image, setImage] = useState<{ preview: string; base64: string } | null>(null);
const [result, setResult] = useState<HealthCheckResult | null>(null);
const [error, setError] = useState<string>('');
const fileInputRef = useRef<HTMLInputElement>(null);
const aiConfigured = isAIConfigured();
useEffect(() => {
if (selectedTreeId) {
const tree = trees.find(t => t.id === selectedTreeId);
if (tree) {
setTreeInfo({ species: tree.species, location: tree.location });
}
} else {
setTreeInfo({ species: '', location: '' });
}
}, [selectedTreeId, trees]);
const handleSelectCategory = (category: (typeof problemCategories)[0]) => {
setSelectedCategory(category);
setStage('uploading_photo');
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.size > 4 * 1024 * 1024) { // 4MB limit
setError("File size exceeds 4MB. Please upload a smaller image.");
return;
}
const reader = new FileReader();
reader.onloadend = () => {
const base64String = (reader.result as string).split(',')[1];
setImage({ preview: reader.result as string, base64: base64String });
setError('');
};
reader.onerror = () => setError("Failed to read the file.");
reader.readAsDataURL(file);
}
};
const handleRunAnalysis = async () => {
if (!image || !treeInfo.species || !treeInfo.location || !selectedCategory) {
setError("Please provide all required information: an image, species, and location.");
return;
}
setStage('analyzing');
setError('');
try {
const analysisResult = await runHealthCheck(image.base64, treeInfo.species, treeInfo.location, selectedCategory.name);
if(analysisResult) {
setResult(analysisResult);
setStage('results');
} else {
throw new Error("Failed to get a diagnosis from the AI. It might be busy, or the image could not be processed. Please try again.");
}
} catch (e: any) {
setError(e.message);
setStage('uploading_photo');
}
};
const handleSaveToLog = () => {
if (!selectedTreeId || !result) {
alert("No tree selected or no result to save.");
return;
};
const newLog: DiaryLog = {
id: `log-${Date.now()}`,
date: new Date().toISOString(),
title: `Health Check: ${result.probableCause}`,
notes: `Ran a diagnostic for "${selectedCategory?.name}". The AI diagnosed the issue with ${result.confidence} confidence.`,
photos: image ? [image.base64] : [],
tags: [LogTag.HealthDiagnosis],
healthCheckResult: result
};
const treeToUpdate = trees.find(t => t.id === selectedTreeId);
if (treeToUpdate) {
const updatedLogs = [newLog, ...treeToUpdate.logs].sort((a,b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const updatedTree = { ...treeToUpdate, logs: updatedLogs };
const newTreeList = trees.map(t => t.id === selectedTreeId ? updatedTree : t);
const storageKey = `yuki-app-bonsai-diary-trees`;
window.localStorage.setItem(storageKey, JSON.stringify(newTreeList));
alert("Diagnosis saved to your tree's log!");
setActiveView('garden');
}
};
const reset = () => {
setStage('selecting_problem');
setSelectedCategory(null);
setImage(null);
setResult(null);
setError('');
// Keep tree selection
};
const renderHeader = () => (
<header className="text-center">
<h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3">
<StethoscopeIcon className="w-8 h-8 text-red-600" />
Bonsai Health Check-up
</h2>
<p className="mt-4 text-lg leading-8 text-stone-600 max-w-2xl mx-auto">
Get a quick, focused diagnosis for a specific problem with your bonsai.
</p>
</header>
);
const renderContent = () => {
switch(stage) {
case 'selecting_problem':
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200">
<h3 className="text-xl font-semibold text-center text-stone-800 mb-2">What seems to be the problem?</h3>
<p className="text-center text-stone-600 mb-6">Select a category to begin the diagnosis.</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{problemCategories.map(cat => {
const Icon = cat.icon;
return (
<div key={cat.name} onClick={() => handleSelectCategory(cat)} className="bg-stone-50 rounded-lg p-6 border-2 border-transparent hover:border-green-600 hover:bg-white cursor-pointer transition-all duration-200 text-center flex flex-col items-center shadow-sm hover:shadow-xl">
<Icon className="w-12 h-12 text-green-700 mb-3" />
<h4 className="font-bold text-stone-900">{cat.name}</h4>
<p className="text-sm text-stone-600 mt-1">{cat.description}</p>
</div>
)})}
</div>
</div>
);
case 'uploading_photo':
return (
<div className="bg-white p-8 rounded-2xl shadow-lg border border-stone-200 max-w-2xl mx-auto space-y-6">
<button onClick={() => setStage('selecting_problem')} className="flex items-center gap-2 text-sm font-semibold text-green-700 hover:text-green-600">
<ArrowLeftIcon className="w-5 h-5"/> Back to Categories
</button>
<div>
<h3 className="text-2xl font-bold text-stone-800">{selectedCategory?.name}</h3>
<p className="text-stone-600 mt-2"><strong className="text-stone-800">Photo Instructions:</strong> {selectedCategory?.instruction}</p>
</div>
<div onClick={() => fileInputRef.current?.click()} className="mt-2 flex justify-center rounded-lg border-2 border-dashed border-stone-300 px-6 py-10 hover:border-green-600 transition-colors cursor-pointer">
<div className="text-center">
{image ? <img src={image.preview} alt="Bonsai preview" className="mx-auto h-40 w-auto rounded-md object-cover" /> : (
<>
<UploadCloudIcon className="mx-auto h-12 w-12 text-stone-400" />
<p className="mt-2 text-sm font-semibold text-green-700">Upload a file or drag and drop</p>
<p className="text-xs text-stone-500">PNG, JPG up to 4MB</p>
</> )}
</div>
<input ref={fileInputRef} type="file" className="sr-only" onChange={handleFileChange} accept="image/png, image/jpeg" />
</div>
<div className="space-y-4">
<select value={selectedTreeId} onChange={(e) => setSelectedTreeId(e.target.value)} className="w-full p-2 border rounded-md" required>
<option value="">Select a tree from your garden...</option>
{trees.map(tree => <option key={tree.id} value={tree.id}>{tree.name} ({tree.species})</option>)}
<option value="new">-- This is a new tree --</option>
</select>
{selectedTreeId === 'new' && (
<div className="grid grid-cols-2 gap-4">
<input type="text" placeholder="Species" value={treeInfo.species} onChange={(e) => setTreeInfo(t => ({...t, species: e.target.value}))} className="w-full p-2 border rounded-md"/>
<input type="text" placeholder="Location" value={treeInfo.location} onChange={(e) => setTreeInfo(t => ({...t, location: e.target.value}))} className="w-full p-2 border rounded-md"/>
</div>
)}
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button onClick={handleRunAnalysis} disabled={!image || !aiConfigured} className="w-full flex items-center justify-center gap-2 rounded-md bg-red-600 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-red-500 disabled:bg-stone-400 disabled:cursor-not-allowed">
<StethoscopeIcon className="w-5 h-5"/> Get Diagnosis
</button>
{!aiConfigured && (
<div className="mt-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center">
<p className="text-sm">
AI features are disabled. Please set your Gemini API key in the{' '}
<button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900">
Settings page
</button>.
</p>
</div>
)}
</div>
);
case 'analyzing':
return <Spinner text={`Yuki is diagnosing the ${selectedCategory?.name.toLowerCase()}...`} />;
case 'results':
if (!result) return null;
const confidenceColor = result.confidence === 'High' ? 'bg-red-100 text-red-800' : result.confidence === 'Medium' ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800';
return (
<div className="bg-white p-8 rounded-2xl shadow-lg border border-stone-200 max-w-3xl mx-auto space-y-6">
<h3 className="text-2xl font-bold text-stone-800">Diagnosis Complete</h3>
<div className="p-4 bg-stone-50 rounded-lg border">
<div className="flex justify-between items-baseline">
<h4 className="text-xl font-bold text-stone-900">{result.probableCause}</h4>
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${confidenceColor}`}>{result.confidence} Confidence</span>
</div>
<p className="mt-2 text-stone-600">{result.explanation}</p>
</div>
<div>
<h4 className="text-lg font-semibold text-stone-800 mb-2">Treatment Plan</h4>
<ol className="space-y-4">
{result.treatmentPlan.map(step => (
<li key={step.step} className="flex gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-green-600 text-white rounded-full flex items-center justify-center font-bold">{step.step}</div>
<div>
<p className="font-bold text-stone-800">{step.action}</p>
<p className="text-stone-600">{step.details}</p>
</div>
</li>))}
</ol>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="p-4 bg-green-50 rounded-lg">
<h4 className="font-semibold text-green-800">Organic Alternatives</h4>
<p className="text-sm text-green-700 mt-1">{result.organicAlternatives}</p>
</div>
<div className="p-4 bg-blue-50 rounded-lg">
<h4 className="font-semibold text-blue-800">Future Prevention</h4>
<p className="text-sm text-blue-700 mt-1">{result.preventativeMeasures}</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4 pt-6 border-t">
<button onClick={reset} className="flex-1 w-full bg-stone-200 text-stone-700 font-semibold py-3 px-6 rounded-lg hover:bg-stone-300 transition-colors">Run New Diagnosis</button>
{selectedTreeId && selectedTreeId !== 'new' && (
<button onClick={handleSaveToLog} className="flex-1 w-full flex items-center justify-center gap-2 rounded-md bg-green-700 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-green-600">
<CheckCircleIcon className="w-5 h-5"/> Save to Garden Log
</button>
)}
</div>
</div>
);
}
}
return (
<div className="space-y-8">
{renderHeader()}
<div>{renderContent()}</div>
</div>
);
};
export default HealthCheckView;