diff --git a/api/ai/parse.ts b/api/ai/parse.ts index 857f47a..d670a96 100644 --- a/api/ai/parse.ts +++ b/api/ai/parse.ts @@ -1,4 +1,5 @@ import { AiParseResult, InventoryPromptItem, Language, validateAiParseResult } from './validation.js'; +import { normalizePantryCategory } from '../../src/utils/pantryCategory.js'; type ParseCookVoiceInputRequest = { input: string; @@ -130,7 +131,7 @@ function buildPrompt(input: string, inventory: InventoryPromptItem[], lang: Lang Task 1: Intent Classification. Is this gibberish, chit-chat, or missing an item name? If yes, set 'understood' to false and provide a helpful 'message' asking for clarification. Task 2: Match their request to the following inventory items: [${inventoryContext}]. Determine the new status ('in-stock', 'low', 'out'). If they specify a quantity (e.g., "2 kilo", "500g", "3 packets"), extract it as 'requestedQuantity'. - Task 3: If they mention an item NOT in the inventory, add it to 'unlistedItems' with a guessed status, a guessed 'category' (e.g., Vegetables, Spices, Dairy, Grains, Meat, Snacks, Cleaning), and any 'requestedQuantity'. + Task 3: If they mention an item NOT in the inventory, add it to 'unlistedItems' with a guessed status, a guessed 'category' using only canonical keys: spices, pulses, staples, veggies, dairy, or other, and any 'requestedQuantity'. Return a JSON object matching this schema.`; } @@ -254,7 +255,14 @@ async function generateAiParseResult(input: string, inventory: InventoryPromptIt for (let attempt = 1; attempt <= MAX_AI_ATTEMPTS; attempt += 1) { try { const parsed = await requestGeminiJson(prompt, apiKey, aiModel, AI_REQUEST_TIMEOUT_MS); - return validateAiParseResult(parsed); + const validated = validateAiParseResult(parsed); + return { + ...validated, + unlistedItems: validated.unlistedItems.map((item) => ({ + ...item, + category: normalizePantryCategory(item.category), + })), + }; } catch (error) { lastError = error; console.warn('ai_parse_attempt_failed', createAttemptWarning(attempt, input, inventory.length, lang, error)); diff --git a/firestore.rules b/firestore.rules index caf60b5..0b14469 100644 --- a/firestore.rules +++ b/firestore.rules @@ -10,6 +10,8 @@ service cloud.firestore { // Fields: // - ownerId: string (required) // - cookEmail: string (optional) + // - ownerLanguage: string (optional, 'en' | 'hi') + // - cookLanguage: string (optional, 'en' | 'hi') // // Collection: households/{householdId}/inventory // Document ID: auto-generated or custom string @@ -122,7 +124,9 @@ service cloud.firestore { ( !('cookEmail' in data) || (data.cookEmail is string && (data.cookEmail == '' || isNormalizedEmail(data.cookEmail))) - ); + ) && + (!('ownerLanguage' in data) || data.ownerLanguage in ['en', 'hi']) && + (!('cookLanguage' in data) || data.cookLanguage in ['en', 'hi']); } function isValidInventoryWrite(householdId, data) { diff --git a/package.json b/package.json index f3d1dbb..ea173f7 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,10 @@ "preview": "vite preview", "clean": "rm -rf dist", "lint": "tsc --noEmit", + "unit:test": "node --import tsx test/unit/run.ts", "rules:test": "node test/rules/check-java.mjs && firebase emulators:exec --only firestore --project demo-rasoi-planner \"tsx test/rules/run.ts\"", "e2e": "node test/e2e/run.mjs", - "verify:local": "npm run lint && npm run build && npm run rules:test && npm run e2e", + "verify:local": "npm run lint && npm run unit:test && npm run build && npm run rules:test && npm run e2e", "prepare": "husky" }, "dependencies": { diff --git a/src/App.tsx b/src/App.tsx index 9a17514..42b6828 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,7 @@ import { import OwnerView from './components/OwnerView'; import CookView from './components/CookView'; import { auth, db, loginWithGoogle, logout } from './firebase'; -import { InventoryItem, InventoryStatus, MealPlan, PantryLog, Role } from './types'; +import { InventoryItem, InventoryStatus, MealPlan, PantryLog, Role, UiLanguage } from './types'; import { toUserFacingError } from './utils/error'; import { addInventoryItem, @@ -24,6 +24,7 @@ import { } from './services/inventoryService'; import { upsertMealField } from './services/mealService'; import { HouseholdData, resolveOrCreateHousehold } from './services/householdService'; +import { getAppCopy } from './i18n/copy'; interface UiFeedback { kind: 'success' | 'error'; @@ -47,6 +48,10 @@ export default function App() { const [isInviting, setIsInviting] = useState(false); const [uiFeedback, setUiFeedback] = useState(null); const isOwner = role === 'owner'; + const ownerLanguage: UiLanguage = householdData?.ownerLanguage ?? 'en'; + const cookLanguage: UiLanguage = householdData?.cookLanguage ?? 'hi'; + const activeLanguage: UiLanguage = isOwner ? ownerLanguage : cookLanguage; + const appCopy = getAppCopy(activeLanguage); const shellWidthClass = isOwner ? 'max-w-7xl' : 'max-w-5xl'; const shellSectionClass = `${shellWidthClass} mx-auto px-4 md:px-6`; const shellMainClass = `${shellWidthClass} mx-auto p-4 md:p-6 pb-24`; @@ -331,6 +336,22 @@ export default function App() { } }; + const handleUpdateLanguagePreference = async (field: 'ownerLanguage' | 'cookLanguage', value: UiLanguage): Promise => { + if (!householdId) { + return; + } + + try { + await updateDoc(doc(db, 'households', householdId), { + [field]: value, + }); + setUiFeedback({ kind: 'success', message: 'Language profile updated.' }); + } catch (error) { + console.error('language_profile_update_failed', { error, householdId, field, value }); + setUiFeedback({ kind: 'error', message: toUserFacingError(error, 'Failed to update language profile.') }); + } + }; + const handleRemoveCook = async (): Promise => { if (!householdId) { return; @@ -370,13 +391,14 @@ export default function App() {

Rasoi Planner

-

Sign in to sync your pantry and meal plans across all devices.

+

{appCopy.signInPrompt}

@@ -388,13 +410,13 @@ export default function App() {
-

Access Removed

-

Your owner removed this cook access. Sign out and ask the owner to invite you again.

+

{appCopy.accessRemoved}

+

{appCopy.accessRemovedDetail}

@@ -413,20 +435,20 @@ export default function App() {

Rasoi Planner

- {isOwner ? 'Owner workspace' : 'Cook workspace'} + {isOwner ? appCopy.ownerWorkspace : appCopy.cookWorkspace}

{isOwner ? : } - {isOwner ? 'Owner' : 'Cook'} + {isOwner ? appCopy.ownerRole : appCopy.cookRole}
@@ -452,12 +474,40 @@ export default function App() {
-

Household Settings

+

{appCopy.householdSettings}

{householdData.cookEmail ? `Cook access granted to: ${householdData.cookEmail}` - : 'Invite your cook to sync the pantry.'} + : appCopy.inviteCookHint}

+
+ + +
{householdData.cookEmail ? ( @@ -465,13 +515,13 @@ export default function App() { onClick={handleRemoveCook} className="px-4 py-2 bg-red-50 text-red-600 rounded-lg text-sm font-bold hover:bg-red-100 transition-colors whitespace-nowrap" > - Remove Cook + {appCopy.removeCook} ) : (
setInviteEmail(event.target.value)} className="px-3 py-2 border border-stone-300 rounded-lg text-sm w-full sm:w-64 focus:ring-2 focus:ring-orange-500 outline-none" @@ -481,7 +531,7 @@ export default function App() { disabled={isInviting || !inviteEmail} className="px-4 py-2 bg-stone-800 text-white rounded-lg text-sm font-bold hover:bg-stone-700 transition-colors disabled:opacity-50 whitespace-nowrap" > - {isInviting ? 'Inviting...' : 'Invite'} + {isInviting ? appCopy.inviting : appCopy.invite}
)} @@ -505,6 +555,7 @@ export default function App() { onDeleteInventoryItem={handleDeleteInventoryItem} onClearAnomaly={handleClearAnomaly} logs={logs} + language={ownerLanguage} /> ) : ( )} diff --git a/src/components/CookView.tsx b/src/components/CookView.tsx index 490910a..4dccfaa 100644 --- a/src/components/CookView.tsx +++ b/src/components/CookView.tsx @@ -1,14 +1,16 @@ import React, { useState } from 'react'; -import { Sun, Moon, AlertCircle, CheckCircle2, Search, Mic, Globe, Info, ShoppingCart, MessageSquarePlus, Check } from 'lucide-react'; -import { MealPlan, InventoryItem, InventoryStatus, Language } from '../types'; +import { Sun, Moon, AlertCircle, CheckCircle2, Search, Mic, Info, ShoppingCart, MessageSquarePlus, Check } from 'lucide-react'; +import { MealPlan, InventoryItem, InventoryStatus, UiLanguage } from '../types'; import { parseCookVoiceInput } from '../services/ai'; import { getLocalDateKey } from '../utils/date'; +import { getCookCopy } from '../i18n/copy'; interface Props { meals: Record; inventory: InventoryItem[]; onUpdateInventory: (id: string, status: InventoryStatus, requestedQuantity?: string) => void; onAddUnlistedItem: (name: string, status: InventoryStatus, category: string, requestedQuantity?: string) => void; + language: UiLanguage; } const DICT = { @@ -56,8 +58,7 @@ const DICT = { } }; -export default function CookView({ meals, inventory, onUpdateInventory, onAddUnlistedItem }: Props) { - const [lang, setLang] = useState('hi'); +export default function CookView({ meals, inventory, onUpdateInventory, onAddUnlistedItem, language }: Props) { const [searchTerm, setSearchTerm] = useState(''); const [aiInput, setAiInput] = useState(''); const [isProcessing, setIsProcessing] = useState(false); @@ -66,7 +67,9 @@ export default function CookView({ meals, inventory, onUpdateInventory, onAddUnl const [editingNoteId, setEditingNoteId] = useState(null); const [noteValue, setNoteValue] = useState(''); + const lang = language; const t = DICT[lang]; + const copy = getCookCopy(lang); const today = getLocalDateKey(new Date()); const todaysMeals = meals[today] || { morning: t.notPlanned, evening: t.notPlanned, notes: undefined, leftovers: undefined }; @@ -157,16 +160,12 @@ export default function CookView({ meals, inventory, onUpdateInventory, onAddUnl

- {lang === 'hi' ? 'कुक वर्कस्पेस' : 'Cook workspace'} + {copy.workspaceTag}

- {lang === 'hi' ? 'आज का मेनू और पेंट्री स्थिति' : t.todayMenu} + {copy.title}

-

- {lang === 'hi' - ? 'मेनू, स्मार्ट अपडेट, और पेंट्री चेक एक ही साफ़-सुथरे सतह पर।' - : 'Keep menu review, AI updates, and pantry checks on one calm surface.'} -

+

{copy.helper}

@@ -174,13 +173,9 @@ export default function CookView({ meals, inventory, onUpdateInventory, onAddUnl Cook View
- + + {copy.switchLabel}: {lang === 'hi' ? 'Hindi + Hinglish' : 'English + Hinglish'} +
@@ -190,10 +185,8 @@ export default function CookView({ meals, inventory, onUpdateInventory, onAddUnl
-

Smart Assistant

-

- {lang === 'hi' ? 'त्वरित स्टेटस अपडेट और अनलिस्टेड आइटम जोड़ें।' : 'Quick status updates and unlisted item requests.'} -

+

{copy.smartAssistant}

+

{copy.smartAssistantHelper}

@@ -203,20 +196,20 @@ export default function CookView({ meals, inventory, onUpdateInventory, onAddUnl value={aiInput} onChange={(e) => setAiInput(e.target.value)} placeholder={t.voicePrompt} + data-testid="cook-ai-input" className="min-w-0 flex-1 rounded-xl border border-white/20 bg-white px-4 py-3 text-stone-800 outline-none transition focus:ring-2 focus:ring-orange-200" disabled={isProcessing} /> -

- Tip: Type something like "Tamatar aur atta khatam ho gaya hai" -

+

{copy.aiTip}

{errorMessage && (
@@ -305,6 +298,7 @@ export default function CookView({ meals, inventory, onUpdateInventory, onAddUnl placeholder={t.search} value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} + data-testid="cook-pantry-search" className="w-full rounded-xl border border-stone-300 bg-white py-3 pl-10 pr-4 text-sm outline-none transition focus:border-orange-500 focus:ring-2 focus:ring-orange-100" />
diff --git a/src/components/GroceryList.tsx b/src/components/GroceryList.tsx index fe4e1f7..3cfc11f 100644 --- a/src/components/GroceryList.tsx +++ b/src/components/GroceryList.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { ShoppingCart, CheckCircle2 } from 'lucide-react'; import { InventoryItem, InventoryStatus } from '../types'; +import { getPantryCategoryLabel } from '../utils/pantryCategory'; interface Props { inventory: InventoryItem[]; @@ -62,13 +63,14 @@ export default function GroceryList({ inventory, onUpdateInventory }: Props) { )}

- {item.category} • {item.status === 'out' ? 'Finished' : 'Running Low'} + {getPantryCategoryLabel(item.category)} • {item.status === 'out' ? 'Finished' : 'Running Low'}