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
12 changes: 10 additions & 2 deletions api/ai/parse.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AiParseResult, InventoryPromptItem, Language, validateAiParseResult } from './validation.js';
import { normalizePantryCategory } from '../../src/utils/pantryCategory.js';

type ParseCookVoiceInputRequest = {
input: string;
Expand Down Expand Up @@ -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.`;
}
Expand Down Expand Up @@ -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));
Expand Down
6 changes: 5 additions & 1 deletion firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
80 changes: 66 additions & 14 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand All @@ -47,6 +48,10 @@ export default function App() {
const [isInviting, setIsInviting] = useState(false);
const [uiFeedback, setUiFeedback] = useState<UiFeedback | null>(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`;
Expand Down Expand Up @@ -331,6 +336,22 @@ export default function App() {
}
};

const handleUpdateLanguagePreference = async (field: 'ownerLanguage' | 'cookLanguage', value: UiLanguage): Promise<void> => {
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<void> => {
if (!householdId) {
return;
Expand Down Expand Up @@ -370,13 +391,14 @@ export default function App() {
</div>
</div>
<h1 className="text-3xl font-bold text-stone-800 mb-2">Rasoi Planner</h1>
<p className="text-stone-500 mb-8">Sign in to sync your pantry and meal plans across all devices.</p>
<p className="text-stone-500 mb-8">{appCopy.signInPrompt}</p>
<button
onClick={loginWithGoogle}
className="w-full bg-orange-600 text-white py-3 px-4 rounded-xl font-bold hover:bg-orange-700 transition-colors flex items-center justify-center gap-2"
data-testid="sign-in-button"
>
<LogIn size={20} />
Sign in with Google
{appCopy.signInWithGoogle}
</button>
</div>
</div>
Expand All @@ -388,13 +410,13 @@ export default function App() {
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-4">
<div className="bg-white p-8 rounded-2xl shadow-sm border border-red-100 max-w-md w-full text-center">
<AlertCircle className="mx-auto text-red-500 mb-4" size={40} />
<h1 className="text-2xl font-bold text-stone-800 mb-2">Access Removed</h1>
<p className="text-stone-500 mb-6">Your owner removed this cook access. Sign out and ask the owner to invite you again.</p>
<h1 className="text-2xl font-bold text-stone-800 mb-2">{appCopy.accessRemoved}</h1>
<p className="text-stone-500 mb-6">{appCopy.accessRemovedDetail}</p>
<button
onClick={logout}
className="w-full bg-stone-800 text-white py-3 px-4 rounded-xl font-bold hover:bg-stone-700 transition-colors"
>
Sign Out
{appCopy.signOut}
</button>
</div>
</div>
Expand All @@ -413,20 +435,20 @@ export default function App() {
<div className="min-w-0">
<h1 className="hidden text-2xl font-bold tracking-tight sm:block">Rasoi Planner</h1>
<p className="text-xs font-medium uppercase tracking-[0.24em] text-orange-100/80">
{isOwner ? 'Owner workspace' : 'Cook workspace'}
{isOwner ? appCopy.ownerWorkspace : appCopy.cookWorkspace}
</p>
</div>
</div>
<div className="flex items-center justify-between gap-3 sm:justify-end">
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1.5 text-sm font-semibold text-white shadow-inner">
{isOwner ? <User size={16} /> : <ChefHat size={16} />}
<span>{isOwner ? 'Owner' : 'Cook'}</span>
<span>{isOwner ? appCopy.ownerRole : appCopy.cookRole}</span>
</div>
<button
onClick={logout}
className="text-sm font-medium text-orange-50 transition-colors hover:text-white"
>
Sign Out
{appCopy.signOut}
</button>
</div>
</div>
Expand All @@ -452,26 +474,54 @@ export default function App() {
<div className={`${shellSectionClass} pt-6`}>
<div className="bg-white p-4 rounded-xl shadow-sm border border-stone-200 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h3 className="font-bold text-stone-800">Household Settings</h3>
<h3 className="font-bold text-stone-800">{appCopy.householdSettings}</h3>
<p className="text-sm text-stone-500">
{householdData.cookEmail
? `Cook access granted to: ${householdData.cookEmail}`
: 'Invite your cook to sync the pantry.'}
: appCopy.inviteCookHint}
</p>
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-500">{appCopy.ownerLanguageLabel}</span>
<select
value={ownerLanguage}
onChange={(event) => void handleUpdateLanguagePreference('ownerLanguage', event.target.value as UiLanguage)}
className="rounded-lg border border-stone-300 bg-white px-3 py-2 text-sm text-stone-700 outline-none focus:border-orange-500 focus:ring-2 focus:ring-orange-100"
data-testid="owner-language-select"
>
<option value="en">English + Hinglish helper</option>
<option value="hi">Hindi + Hinglish helper</option>
</select>
<span className="text-xs text-stone-500">{appCopy.ownerLanguageHint}</span>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-500">{appCopy.cookLanguageLabel}</span>
<select
value={cookLanguage}
onChange={(event) => void handleUpdateLanguagePreference('cookLanguage', event.target.value as UiLanguage)}
className="rounded-lg border border-stone-300 bg-white px-3 py-2 text-sm text-stone-700 outline-none focus:border-orange-500 focus:ring-2 focus:ring-orange-100"
data-testid="cook-language-select"
>
<option value="hi">Hindi + Hinglish helper</option>
<option value="en">English + Hinglish helper</option>
</select>
<span className="text-xs text-stone-500">{appCopy.cookLanguageHint}</span>
</label>
</div>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
{householdData.cookEmail ? (
<button
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}
</button>
) : (
<div className="flex w-full sm:w-auto gap-2">
<input
type="email"
placeholder="Cook's Gmail address"
placeholder={appCopy.inviteCookPlaceholder}
value={inviteEmail}
onChange={(event) => 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"
Expand All @@ -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}
</button>
</div>
)}
Expand All @@ -505,13 +555,15 @@ export default function App() {
onDeleteInventoryItem={handleDeleteInventoryItem}
onClearAnomaly={handleClearAnomaly}
logs={logs}
language={ownerLanguage}
/>
) : (
<CookView
meals={meals}
inventory={inventory}
onUpdateInventory={handleUpdateInventory}
onAddUnlistedItem={handleAddUnlistedItem}
language={cookLanguage}
/>
)}
</main>
Expand Down
Loading
Loading