Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 51 additions & 18 deletions frontend/src/components/FoodSearchInput.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ export default function FoodSearchInput({ onFoodSelect, initialValue = '' }) {
const [loading, setLoading] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const [quantity, setQuantity] = useState(1);
const [error, setError] = useState(null);
const dropdownRef = useRef(null);
const requestIdRef = useRef(0);

useEffect(() => {
const handleClickOutside = (event) => {
Expand All @@ -35,33 +37,48 @@ export default function FoodSearchInput({ onFoodSelect, initialValue = '' }) {
}, []);

useEffect(() => {
setError(null);
if (query.length < 2) {
requestIdRef.current += 1;
setResults([]);
setCustomFoods([]);
setLoading(false);
return;
}

const controller = new AbortController();
const timer = setTimeout(async () => {
const requestId = ++requestIdRef.current;
const isStale = () => controller.signal.aborted || requestId !== requestIdRef.current;
setLoading(true);
try {
const [dbResponse, customResponse] = await Promise.all([
api.get(`/search-food?query=${encodeURIComponent(query)}`),
api.get(`/search-food?query=${encodeURIComponent(query)}`, { signal: controller.signal }),
searchCustomFoods(query)
]);
if (isStale()) return;

setResults(dbResponse.data.foods || []);
setCustomFoods(customResponse);
setError(null);
setShowDropdown(true);
} catch (error) {
console.error('[FoodSearch] Search failed:', error);
} catch (err) {
const canceled = err?.name === 'CanceledError' || err?.code === 'ERR_CANCELED';
if (canceled || isStale()) return;
console.error('[FoodSearch] Search failed:', err);
setResults([]);
setCustomFoods([]);
setShowDropdown(false);
setError('Search unavailable, try again');
} finally {
setLoading(false);
if (requestId === requestIdRef.current) setLoading(false);
}
}, 500);

return () => clearTimeout(timer);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);

const searchCustomFoods = async (searchQuery) => {
Expand Down Expand Up @@ -111,6 +128,18 @@ export default function FoodSearchInput({ onFoodSelect, initialValue = '' }) {
setQuantity(1);
};

const handleAddManually = () => {
onFoodSelect({
name: query,
portion: `${quantity} serving`,
calories: 0,
protein_g: 0,
carbs_g: 0,
fat_g: 0,
fiber_g: 0,
});
};

return (
<div className="relative" ref={dropdownRef}>
<div className="flex flex-col sm:flex-row gap-2">
Expand Down Expand Up @@ -204,24 +233,28 @@ export default function FoodSearchInput({ onFoodSelect, initialValue = '' }) {
</div>
)}

{showDropdown && !loading && results.length === 0 && customFoods.length === 0 && query.length >= 2 && (
{error && !loading && query.length >= 2 && (
<div role="alert" className="absolute top-full left-0 right-0 mt-1 bg-[#0a0a0a] border border-red-500/40 shadow-lg p-4 z-50">
<p className="text-center text-red-400 text-sm mb-3">
{error}
</p>
<button
onClick={handleAddManually}
className="w-full px-4 py-2 bg-primary-700 text-white hover:bg-primary-600 text-sm font-medium flex items-center justify-center gap-2"
>
<Plus size={16} />
Add "{query}" manually
</button>
</div>
)}

{showDropdown && !error && !loading && results.length === 0 && customFoods.length === 0 && query.length >= 2 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-[#0a0a0a] border border-white/10 shadow-lg p-4 z-50">
<p className="text-center text-white/50 text-sm mb-3">
No foods found. Try a different search term or add manually:
</p>
<button
onClick={() => {
const manualFood = {
name: query,
portion: `${quantity} serving`,
calories: 0,
protein_g: 0,
carbs_g: 0,
fat_g: 0,
fiber_g: 0,
};
onFoodSelect(manualFood);
}}
onClick={handleAddManually}
className="w-full px-4 py-2 bg-primary-700 text-white hover:bg-primary-600 text-sm font-medium flex items-center justify-center gap-2"
>
<Plus size={16} />
Expand Down
95 changes: 93 additions & 2 deletions frontend/src/components/__tests__/FoodSearchInput.test.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import FoodSearchInput from '../FoodSearchInput';

// Mock api service
Expand Down Expand Up @@ -33,6 +33,10 @@ describe('FoodSearchInput Component', () => {
vi.clearAllMocks();
});

afterEach(() => {
vi.useRealTimers();
});

it('renders search input', () => {
render(<FoodSearchInput onFoodSelect={mockOnSelect} />);

Expand Down Expand Up @@ -138,4 +142,91 @@ describe('FoodSearchInput Component', () => {
const searchInput = screen.getByPlaceholderText(/search/i);
expect(searchInput.value).toBe('chicken');
});

describe('search error + stale responses', () => {
const food = (name) => ({
name, portion: '100g', calories: 100, protein_g: 1, carbs_g: 1, fat_g: 1, fiber_g: 0,
});

it('shows error state (not "No foods found") when backend fails', async () => {
const err = new Error('Request failed with status code 503');
api.get.mockRejectedValue(err);
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

render(<FoodSearchInput onFoodSelect={mockOnSelect} />);
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'chicken' } });

expect(await screen.findByText(/search unavailable, try again/i, {}, { timeout: 3000 })).toBeInTheDocument();
expect(screen.queryByText(/no foods found/i)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /add "chicken" manually/i })).toBeInTheDocument();
errSpy.mockRestore();
});

it('shows "No foods found" only on successful empty response', async () => {
api.get.mockResolvedValue({ data: { foods: [] } });

render(<FoodSearchInput onFoodSelect={mockOnSelect} />);
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'zzzz' } });

expect(await screen.findByText(/no foods found/i, {}, { timeout: 3000 })).toBeInTheDocument();
expect(screen.queryByText(/search unavailable/i)).not.toBeInTheDocument();
});

it('ignores stale responses and passes an AbortSignal', async () => {
const deferred = {};
api.get.mockImplementation((url) => {
const q = new URL(url, 'http://x').searchParams.get('query');
return new Promise((resolve, reject) => {
deferred[q] = { resolve, reject };
});
});

render(<FoodSearchInput onFoodSelect={mockOnSelect} />);
const input = screen.getByPlaceholderText(/search/i);

// slow request for 'chi'
fireEvent.change(input, { target: { value: 'chi' } });
await waitFor(() => expect(deferred.chi).toBeDefined(), { timeout: 3000 });

// fast request for 'chicken'
fireEvent.change(input, { target: { value: 'chicken' } });
await waitFor(() => expect(deferred.chicken).toBeDefined(), { timeout: 3000 });

await act(async () => {
deferred.chicken.resolve({ data: { foods: [food('Chicken Breast')] } });
});
expect(await screen.findByText('Chicken Breast')).toBeInTheDocument();

// slow one resolves last, must be ignored
await act(async () => {
deferred.chi.resolve({ data: { foods: [food('Chia Seeds')] } });
});
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});

expect(screen.queryByText('Chia Seeds')).not.toBeInTheDocument();
expect(screen.getByText('Chicken Breast')).toBeInTheDocument();
expect(screen.queryByText(/searching/i)).not.toBeInTheDocument();
expect(api.get).toHaveBeenCalledWith(
expect.stringContaining('query=chi'),
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(api.get.mock.calls[0][1].signal.aborted).toBe(true);
});

it('does not show error when a request is canceled', async () => {
const cancel = Object.assign(new Error('canceled'), { name: 'CanceledError', code: 'ERR_CANCELED' });
api.get.mockRejectedValue(cancel);

render(<FoodSearchInput onFoodSelect={mockOnSelect} />);
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'chicken' } });

await waitFor(() => expect(api.get).toHaveBeenCalled(), { timeout: 3000 });
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});
expect(screen.queryByText(/search unavailable/i)).not.toBeInTheDocument();
});
});
});
8 changes: 7 additions & 1 deletion supabase/functions/food-details/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ Deno.serve(async (req) => {
return jsonResponse({ detail: "food_id must be provided as a path segment" }, { status: 422 }, origin);
}

const usdaApiKey = Deno.env.get("USDA_API_KEY") ?? "DEMO_KEY";
const envKey = Deno.env.get("USDA_API_KEY");
if (!envKey) console.warn("[FOOD DETAILS] USDA_API_KEY not set, falling back to DEMO_KEY (heavily rate-limited)");
const usdaApiKey = envKey ?? "DEMO_KEY";

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10_000);
Expand All @@ -34,6 +36,10 @@ Deno.serve(async (req) => {
clearTimeout(timeoutId);
}

if (usdaRes.status === 429) {
return jsonResponse({ detail: "Food database rate-limited, try again shortly" }, { status: 503 }, origin);
}

if (!usdaRes.ok) {
return jsonResponse({ detail: "Food database unavailable" }, { status: 500 }, origin);
}
Expand Down
8 changes: 7 additions & 1 deletion supabase/functions/search-food/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ Deno.serve(async (req) => {
return jsonResponse({ foods: [] }, { status: 200 }, origin);
}

const usdaApiKey = Deno.env.get("USDA_API_KEY") ?? "DEMO_KEY";
const envKey = Deno.env.get("USDA_API_KEY");
if (!envKey) console.warn("[FOOD SEARCH] USDA_API_KEY not set, falling back to DEMO_KEY (heavily rate-limited)");
const usdaApiKey = envKey ?? "DEMO_KEY";

console.log(`[FOOD SEARCH] Searching for: ${query}`);

Expand Down Expand Up @@ -50,6 +52,10 @@ Deno.serve(async (req) => {

console.log(`[FOOD SEARCH] Status: ${usdaRes.status}`);

if (usdaRes.status === 429) {
return jsonResponse({ detail: "Food database rate-limited, try again shortly" }, { status: 503 }, origin);
}

if (!usdaRes.ok) {
const errText = await usdaRes.text();
console.log(`[FOOD SEARCH] Error response: ${errText}`);
Expand Down
Loading