diff --git a/.env.example b/.env.example index 96689b0..92dda81 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,7 @@ # Required for local development when testing AI-assisted pantry parsing. # Set this in your local .env.local and in Vercel project environment variables. GEMINI_API_KEY="YOUR_GEMINI_API_KEY" + +# Base URL for pinned catalog images (owned CDN/bucket). Required. +# Example: https://cdn.example.com/rasoi/ingredients +VITE_INGREDIENT_IMAGE_BASE_URL="https://cdn.example.com/rasoi/ingredients" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 827ee6b..435e1d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,8 +26,76 @@ jobs: node-version: 20 cache: npm + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + - name: Install dependencies run: npm ci - name: Run full verification run: npm run verify:local + + detect-firestore-changes: + name: detect-firestore-changes + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + firestore_changed: ${{ steps.filter.outputs.firestore_changed }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Detect firestore-related changes + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + firestore_changed: + - firestore.rules + - firebase.json + - firebase-applet-config.json + - scripts/print-firestore-rules-target.mjs + - scripts/smoke-firestore-unknown-queue.mjs + + firestore-rules-deploy: + name: firestore-rules-deploy + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.detect-firestore-changes.outputs.firestore_changed == 'true' + needs: + - verify-local + - detect-firestore-changes + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Verify firestore deploy target + run: npm run rules:target:check + + - name: Deploy Firestore rules (production) + env: + FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} + run: npm run rules:deploy:prod + + - name: Smoke test unknown ingredient queue access (production) + env: + SMOKE_OWNER_EMAIL: ${{ secrets.SMOKE_OWNER_EMAIL }} + SMOKE_OWNER_PASSWORD: ${{ secrets.SMOKE_OWNER_PASSWORD }} + SMOKE_OWNER_HOUSEHOLD_ID: ${{ secrets.SMOKE_OWNER_HOUSEHOLD_ID }} + SMOKE_NON_MEMBER_EMAIL: ${{ secrets.SMOKE_NON_MEMBER_EMAIL }} + SMOKE_NON_MEMBER_PASSWORD: ${{ secrets.SMOKE_NON_MEMBER_PASSWORD }} + run: npm run rules:smoke:prod diff --git a/ACTIVITY_LOG.md b/ACTIVITY_LOG.md new file mode 100644 index 0000000..a0fe4f2 --- /dev/null +++ b/ACTIVITY_LOG.md @@ -0,0 +1,17 @@ +# Activity Log + +## 2026-03-25 + +- Reviewed owner/cook flows, inventory transitions, AI parsing validation, multilingual pantry labels, and activity-log rendering. +- Added `QA_EDGE_CASE_MATRIX.md` with 34 prioritized edge cases covering the requested risk areas. +- Expanded automated coverage in `test/unit/run.ts` and `test/rules/run.ts` for AI parse validation, localized labels, deterministic log construction, and inventory/log write consistency. +- Added deterministic ingredient visual resolver and applied image + fallback rendering across cook, grocery, and pantry surfaces. +- Refined owner-side UX hierarchy (settings grouping, owner tab affordance, responsive meal planner card layout). +- Validation completed: `npm run lint`, `npm run unit:test`, `npm run build`, and `npm run rules:test` (rules test executed successfully outside sandbox due emulator port restrictions in sandbox mode). +- Added 10 follow-up QA edge cases for future ingredient additions, bilingual naming recognition, responsive owner/pantry layouts, and owner-tab keyboard navigation. +- Appended targeted unit coverage in `test/unit/run.ts` for bilingual ingredient matching, catalog fallback behavior for future items, and category search aliases. +- Validation commands/results: + - `npm run lint` -> passed + - `npm run unit:test` -> passed + - `npm run build` -> passed + - `npm run rules:test` -> passed (outside sandbox) diff --git a/QA_EDGE_CASE_MATRIX.md b/QA_EDGE_CASE_MATRIX.md new file mode 100644 index 0000000..dce4658 --- /dev/null +++ b/QA_EDGE_CASE_MATRIX.md @@ -0,0 +1,38 @@ +# QA Edge Case Matrix + +| ID | Priority | Area | Edge case | Expected result | +|---|---|---|---|---| +| Q-01 | P0 | Owner flow | Owner signs in with the matching household owner UID. | Owner dashboard loads with settings, meals, grocery, and pantry access. | +| Q-02 | P0 | Cook flow | Cook signs in with the invited household email. | Cook dashboard loads with the cook workspace and pantry check tools. | +| Q-03 | P0 | Access control | Cook signs in after access is removed by the owner. | Access removed screen appears and the cook cannot continue. | +| Q-04 | P1 | Owner flow | Owner changes the cook invite email casing before saving. | Email is normalized to lowercase and persisted consistently. | +| Q-05 | P1 | Multilingual labels | Owner language is switched to Hindi. | Owner-facing labels update to Hindi copy. | +| Q-06 | P1 | Multilingual labels | Cook language is switched to English. | Cook-facing labels update to English copy. | +| Q-07 | P0 | Inventory transition | In-stock item is marked low by the owner. | Pantry item updates, and a matching activity log entry is written. | +| Q-08 | P0 | Inventory transition | In-stock item is marked out within the anomaly window. | Verification warning appears with a clear anomaly reason. | +| Q-09 | P0 | Inventory transition | Low or out item gets a note from the cook. | Requested quantity persists and renders on reopen. | +| Q-10 | P1 | Inventory transition | Owner clears a verification warning after review. | Warning text disappears while item status stays unchanged. | +| Q-11 | P1 | Inventory transition | Owner adds a new pantry item with a custom quantity. | Item is saved with normalized category and default quantity. | +| Q-12 | P1 | Search behavior | Pantry search uses the English alias `ration`. | Staples items match and remain visible. | +| Q-13 | P1 | Search behavior | Pantry search uses the Hindi label `मुख्य`. | Staples items match through localized category copy. | +| Q-14 | P0 | AI parsing | AI returns a valid response with multiple updates and one unlisted item. | All valid updates apply and the unlisted item is created. | +| Q-15 | P0 | AI parsing | AI response sets `understood` to a non-boolean value. | Parsing fails safely with no writes. | +| Q-16 | P0 | AI parsing | AI response has malformed arrays or invalid item shapes. | Parsing fails safely and the cook view stays usable. | +| Q-17 | P1 | AI parsing | AI response contains one valid update and one unknown item. | Valid updates apply and the partial-match warning appears. | +| Q-18 | P1 | AI parsing | AI response includes a quantity like `2kg`. | Requested quantity is preserved on the inventory item and note display. | +| Q-19 | P1 | Multilingual labels | Pantry labels are viewed in English and Hindi for the same category. | English and Hindi names stay aligned for the same pantry category. | +| Q-20 | P1 | Multilingual labels | Cook helper text switches with the selected language. | Assistant hints and button labels match the current language. | +| Q-21 | P0 | Activity logs | Every successful pantry status change writes a log entry. | Logs tab shows the new entry immediately after the change. | +| Q-22 | P0 | Activity logs | Log entry is created for the correct actor and item. | Role, item name, and status text match the write that occurred. | +| Q-23 | P1 | Activity logs | Multiple updates happen in sequence. | Logs are ordered newest-first and no entry is lost. | +| Q-24 | P1 | Activity logs | No pantry writes have occurred yet. | Empty-state copy is shown instead of stale or partial logs. | +| Q-25 | P1 | Future ingredient additions | Owner adds a new pantry item that is not in the ingredient catalog yet, such as `Curry Leaves`. | The item saves successfully with a category fallback icon and no dependency on catalog metadata. | +| Q-26 | P1 | Future ingredient additions | Owner adds a new pantry item with punctuation and casing variation, such as `Red Chilli Powder (Kashmiri)`. | The item name is preserved, the category is normalized, and the best matching visual path is still selected. | +| Q-27 | P1 | Future ingredient additions | Cook submits an unlisted ingredient request for a future inventory item. | The new item is created, stays readable on reopen, and keeps the requested quantity intact. | +| Q-28 | P1 | Bilingual naming recognition | Ingredient matching is attempted with English and Hindi aliases for the same item, such as `Cumin Seeds` and `जीरा`. | Search and visual resolution land on the same pantry item instead of creating a duplicate. | +| Q-29 | P1 | Bilingual naming recognition | Ingredient matching is attempted with Hindi first and an English alias, such as `नमक` and `Salt`. | The same pantry item is recognized correctly in both directions. | +| Q-30 | P1 | Bilingual naming recognition | Pantry search uses a mixed-language query like `atta आटा` or `sabzi सब्ज़ियाँ`. | The expected item or category stays visible and unrelated rows remain hidden. | +| Q-31 | P2 | Responsive behavior | Owner workspace is viewed on a narrow mobile viewport. | The tab row stacks cleanly, labels stay readable, and horizontal overflow does not block section switching. | +| Q-32 | P2 | Responsive behavior | Pantry add form is viewed on a small screen while typing a long ingredient name. | The inputs stack vertically and the add button remains fully usable without clipping. | +| Q-33 | P2 | Owner tab keyboard interactions | Keyboard user tabs to the owner tablist and activates Grocery or Pantry with Enter or Space. | The selected tab changes without a mouse and the visible panel updates immediately. | +| Q-34 | P2 | Owner tab keyboard interactions | Keyboard user navigates the stacked owner tabs on a narrow screen. | Focus remains visible, the active tab state stays clear, and wrapped labels do not trap keyboard navigation. | diff --git a/README.md b/README.md index a0c8e98..bbe158c 100644 --- a/README.md +++ b/README.md @@ -206,8 +206,24 @@ These constraints are enforced in `firestore.rules` and validated by `test/rules ### Firebase - Firestore security rules source: `firestore.rules` - Local emulator config: `firebase.json` +- App uses Firestore named database: `ai-studio-3900af62-0bf5-496a-a136-d1c8a0c4b8bd` - Confirm production Firebase Auth domain setup before release (Google provider and authorized domains) +### Production Firestore Rules Runbook +1. Mainline path (default): + - Merge to `main`; CI deploys `firestore.rules` automatically when Firestore files change. +2. CI deploy preconditions: + - `verify-local` must pass. + - `npm run rules:target:check` must pass (project/database target integrity gate). +3. Post-deploy smoke checks: + - Owner smoke user must read `households/{householdId}/unknownIngredientQueue`. + - Non-member smoke user must receive `PERMISSION_DENIED`. +4. Emergency/manual deploy only: + - `npm run rules:deploy:prod` + - `npm run rules:smoke:prod` +5. Optional deploy diagnostics: + - `npm run rules:deploy:prod:dry` + ## GitHub-Vercel Sync Workflow This project uses GitHub as the deployment source of truth. @@ -229,6 +245,11 @@ This project uses GitHub as the deployment source of truth. - CI command chain: - `npm ci` - `npm run verify:local` + - `verify-local` includes `npm run rules:target:check` +- `main` push additional automation: + - Detect Firestore-related file changes. + - Deploy Firestore rules automatically when changed. + - Run production smoke test for owner-allow and non-member-deny unknown queue reads. ### Local push gate (Husky) - Husky install hook is configured via `npm run prepare`. @@ -241,6 +262,13 @@ This project uses GitHub as the deployment source of truth. - Require status checks to pass before merging. - Add required status check: `verify-local`. - Require branches to be up to date before merging. +- Add repository secrets for Firestore deploy/smoke workflow: + - `FIREBASE_TOKEN` + - `SMOKE_OWNER_EMAIL` + - `SMOKE_OWNER_PASSWORD` + - `SMOKE_OWNER_HOUSEHOLD_ID` + - `SMOKE_NON_MEMBER_EMAIL` + - `SMOKE_NON_MEMBER_PASSWORD` ### Required Vercel settings - Git repository connected to this GitHub repo. @@ -261,6 +289,13 @@ This project uses GitHub as the deployment source of truth. ### Firestore rules tests fail with Java/emulator error - Install Java 17+ and confirm `java -version` resolves correctly in shell. +### `Unknown ingredient queue access denied... [build:]` +- Confirm the visible build id is the latest deployment. +- Validate CI Firestore deploy and smoke test status on latest `main` run. +- For emergency recovery, deploy + smoke manually: + - `npm run rules:deploy:prod` + - `npm run rules:smoke:prod` + ### Google sign-in popup fails locally - Add `localhost` / `127.0.0.1` to Firebase Auth authorized domains. - Ensure browser popup blocking is disabled for local app. diff --git a/firebase.json b/firebase.json index 450c92e..9f19439 100644 --- a/firebase.json +++ b/firebase.json @@ -1,7 +1,14 @@ { - "firestore": { - "rules": "firestore.rules" - }, + "firestore": [ + { + "database": "(default)", + "rules": "firestore.rules" + }, + { + "database": "ai-studio-3900af62-0bf5-496a-a136-d1c8a0c4b8bd", + "rules": "firestore.rules" + } + ], "emulators": { "firestore": { "host": "127.0.0.1", diff --git a/firestore.rules b/firestore.rules index 0b14469..b11920f 100644 --- a/firestore.rules +++ b/firestore.rules @@ -45,6 +45,21 @@ service cloud.firestore { // - newStatus: string (required, 'in-stock', 'low', 'out') // - timestamp: string (required, ISO date) // - role: string (required, 'owner', 'cook') + // + // Collection: households/{householdId}/unknownIngredientQueue + // Document ID: auto-generated + // Fields: + // - name: string (required, max 100 chars) + // - category: string (required, max 50 chars) + // - status: string (required, 'open', 'resolved') + // - requestedStatus: string (required, 'in-stock', 'low', 'out') + // - createdAt: string (required, ISO date) + // - createdBy: string (required, 'owner', 'cook') + // - requestedQuantity: string (optional, max 50 chars) + // - resolvedAt: string (optional, ISO date) + // - resolvedBy: string (optional, 'owner', 'cook') + // - resolution: string (optional, 'promoted', 'dismissed') + // - promotedInventoryItemId: string (optional, max 100 chars) // =============================================================== function isAuthenticated() { @@ -129,6 +144,43 @@ service cloud.firestore { (!('cookLanguage' in data) || data.cookLanguage in ['en', 'hi']); } + function isValidUnknownIngredientQueueItem(data) { + return data.keys().hasAll(['name', 'category', 'status', 'requestedStatus', 'createdAt', 'createdBy']) && + isValidString(data.name, 1, 100) && + isValidString(data.category, 1, 50) && + data.status in ['open', 'resolved'] && + data.requestedStatus in ['in-stock', 'low', 'out'] && + data.createdAt is string && + data.createdBy in ['owner', 'cook'] && + (!('requestedQuantity' in data) || isValidOptionalString(data.requestedQuantity, 0, 50)) && + (!('resolvedAt' in data) || data.resolvedAt is string) && + (!('resolvedBy' in data) || data.resolvedBy in ['owner', 'cook']) && + (!('resolution' in data) || data.resolution in ['promoted', 'dismissed']) && + (!('promotedInventoryItemId' in data) || isValidOptionalString(data.promotedInventoryItemId, 1, 100)); + } + + function isValidUnknownQueueCreate(householdId, data) { + return data.status == 'open' && + data.createdBy == effectiveRole(householdId) && + !('resolvedAt' in data) && + !('resolvedBy' in data) && + !('resolution' in data) && + !('promotedInventoryItemId' in data); + } + + function isValidUnknownQueueResolve(householdId, currentData, nextData) { + return currentData.status == 'open' && + nextData.status == 'resolved' && + nextData.createdAt == currentData.createdAt && + nextData.createdBy == currentData.createdBy && + nextData.name == currentData.name && + nextData.category == currentData.category && + nextData.requestedStatus == currentData.requestedStatus && + nextData.resolvedBy == effectiveRole(householdId) && + nextData.resolution in ['promoted', 'dismissed'] && + nextData.resolvedAt is string; + } + function isValidInventoryWrite(householdId, data) { return !('updatedBy' in data) || data.updatedBy == effectiveRole(householdId); } @@ -198,6 +250,17 @@ service cloud.firestore { allow update: if false; // Logs are immutable allow delete: if isOwner(householdId); } + + match /unknownIngredientQueue/{queueId} { + allow read: if isHouseholdMember(householdId); + allow create: if isHouseholdMember(householdId) && + isValidUnknownIngredientQueueItem(request.resource.data) && + isValidUnknownQueueCreate(householdId, request.resource.data); + allow update: if isOwner(householdId) && + isValidUnknownIngredientQueueItem(request.resource.data) && + isValidUnknownQueueResolve(householdId, resource.data, request.resource.data); + allow delete: if false; + } } // Keep legacy users path for migration diff --git a/package.json b/package.json index ea173f7..2661b67 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,13 @@ "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\"", + "rules:target:check": "node scripts/print-firestore-rules-target.mjs", + "rules:deploy:prod": "npx firebase deploy --only firestore:rules --project gen-lang-client-0862152879", + "rules:deploy:prod:dry": "npx firebase deploy --only firestore:rules --project gen-lang-client-0862152879 --debug", + "rules:smoke:prod": "node scripts/smoke-firestore-unknown-queue.mjs", "e2e": "node test/e2e/run.mjs", - "verify:local": "npm run lint && npm run unit:test && npm run build && npm run rules:test && npm run e2e", + "e2e:headed": "E2E_HEADLESS=false node test/e2e/run.mjs", + "verify:local": "npm run rules:target:check && npm run lint && npm run unit:test && npm run build && npm run rules:test && npm run e2e", "prepare": "husky" }, "dependencies": { diff --git a/scripts/print-firestore-rules-target.mjs b/scripts/print-firestore-rules-target.mjs new file mode 100644 index 0000000..f907371 --- /dev/null +++ b/scripts/print-firestore-rules-target.mjs @@ -0,0 +1,56 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +async function readJsonFile(filePath) { + const raw = await readFile(filePath, 'utf-8'); + return JSON.parse(raw); +} + +function getFirestoreTargets(firebaseConfig) { + const firestoreConfig = firebaseConfig.firestore; + if (Array.isArray(firestoreConfig)) { + return firestoreConfig; + } + + if (firestoreConfig && typeof firestoreConfig === 'object') { + return [{ database: '(default)', ...firestoreConfig }]; + } + + return []; +} + +async function main() { + const rootDir = process.cwd(); + const appConfigPath = path.join(rootDir, 'firebase-applet-config.json'); + const firebaseJsonPath = path.join(rootDir, 'firebase.json'); + + const appConfig = await readJsonFile(appConfigPath); + const firebaseConfig = await readJsonFile(firebaseJsonPath); + + const projectId = String(appConfig.projectId ?? '').trim(); + const databaseId = String(appConfig.firestoreDatabaseId ?? '').trim(); + const targets = getFirestoreTargets(firebaseConfig); + const databases = targets + .map((target) => String(target.database ?? '').trim()) + .filter((value) => value.length > 0); + + const hasDefaultTarget = databases.includes('(default)'); + const hasNamedTarget = databases.includes(databaseId); + + console.log('Firestore deploy target verification'); + console.log(`- projectId: ${projectId}`); + console.log(`- app databaseId: ${databaseId}`); + console.log(`- firebase.json targets: ${databases.join(', ') || '(none)'}`); + console.log('- recommended deploy command:'); + console.log(` npx firebase deploy --only firestore:rules --project ${projectId}`); + + if (!hasDefaultTarget || !hasNamedTarget) { + console.error('Firestore target mismatch detected in firebase.json.'); + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error('Failed to verify Firestore deploy targets.', error); + process.exitCode = 1; +}); diff --git a/scripts/smoke-firestore-unknown-queue.mjs b/scripts/smoke-firestore-unknown-queue.mjs new file mode 100644 index 0000000..58d1feb --- /dev/null +++ b/scripts/smoke-firestore-unknown-queue.mjs @@ -0,0 +1,298 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +/** + * @typedef {{ + * apiKey: string; + * projectId: string; + * databaseId: string; + * }} AppFirebaseConfig + */ + +/** + * @typedef {{ + * email: string; + * password: string; + * }} Credentials + */ + +function toNonEmptyString(value) { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +async function readAppFirebaseConfig() { + const configPath = path.resolve(process.cwd(), 'firebase-applet-config.json'); + const raw = await readFile(configPath, 'utf-8'); + const parsed = JSON.parse(raw); + + return { + apiKey: String(parsed.apiKey ?? '').trim(), + databaseId: String(parsed.firestoreDatabaseId ?? '').trim(), + projectId: String(parsed.projectId ?? '').trim(), + }; +} + +function requireEnv(name) { + const value = toNonEmptyString(process.env[name]); + if (value === null) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function fetchWithRetries(url, init, attempts, delayMs) { + /** @type {unknown} */ + let lastError = null; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const response = await fetch(url, init); + if (response.status >= 500 && attempt < attempts) { + console.warn('external_request_retry', { + attempt, + reason: 'server_error', + status: response.status, + url, + }); + await sleep(delayMs); + continue; + } + return response; + } catch (error) { + lastError = error; + if (attempt < attempts) { + console.warn('external_request_retry', { + attempt, + reason: 'network_error', + url, + }); + await sleep(delayMs); + continue; + } + } + } + + throw new Error('External request failed after retries.', { cause: lastError }); +} + +async function parseJsonResponse(response) { + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return response.json(); + } + + const bodyText = await response.text(); + if (bodyText.length === 0) { + return null; + } + + try { + return JSON.parse(bodyText); + } catch (error) { + throw new Error('Failed to parse JSON response body.', { + cause: error, + }); + } +} + +async function signInWithEmailAndPassword(config, credentials) { + const signInUrl = `https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${encodeURIComponent(config.apiKey)}`; + + const response = await fetchWithRetries( + signInUrl, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + email: credentials.email, + password: credentials.password, + returnSecureToken: true, + }), + }, + 3, + 500, + ); + + const responseBody = await parseJsonResponse(response); + + if (!response.ok) { + throw new Error('Failed to authenticate smoke-test user.', { + cause: { + request: { + email: credentials.email, + endpoint: 'accounts:signInWithPassword', + }, + response: responseBody, + status: response.status, + }, + }); + } + + const idToken = toNonEmptyString(responseBody?.idToken); + const localId = toNonEmptyString(responseBody?.localId); + + if (idToken === null || localId === null) { + throw new Error('Smoke-test authentication response is missing required token fields.', { + cause: { + request: { + email: credentials.email, + endpoint: 'accounts:signInWithPassword', + }, + response: responseBody, + status: response.status, + }, + }); + } + + return { + idToken, + localId, + }; +} + +async function readUnknownIngredientQueue(config, householdId, idToken) { + const encodedHouseholdId = encodeURIComponent(householdId); + const endpoint = `https://firestore.googleapis.com/v1/projects/${encodeURIComponent(config.projectId)}/databases/${encodeURIComponent(config.databaseId)}/documents/households/${encodedHouseholdId}/unknownIngredientQueue?pageSize=1`; + + const response = await fetchWithRetries( + endpoint, + { + method: 'GET', + headers: { + authorization: `Bearer ${idToken}`, + }, + }, + 3, + 500, + ); + + const responseBody = await parseJsonResponse(response); + + return { + ok: response.ok, + status: response.status, + body: responseBody, + }; +} + +function isPermissionDeniedFirestoreResponse(readResult) { + if (!readResult || typeof readResult !== 'object') { + return false; + } + + const body = readResult.body; + const code = body?.error?.status; + const message = body?.error?.message; + + return readResult.status === 403 && code === 'PERMISSION_DENIED' && typeof message === 'string'; +} + +async function assertOwnerCanReadQueue(config, householdId, ownerCredentials) { + const ownerAuth = await signInWithEmailAndPassword(config, ownerCredentials); + const readResult = await readUnknownIngredientQueue(config, householdId, ownerAuth.idToken); + + if (!readResult.ok) { + throw new Error('Owner smoke check failed: unknown queue read was denied.', { + cause: { + actor: 'owner', + householdId, + readResult, + }, + }); + } + + console.info('smoke_check_owner_access_ok', { + actor: ownerCredentials.email, + householdId, + status: readResult.status, + }); +} + +async function assertNonMemberIsDenied(config, householdId, nonMemberCredentials) { + const nonMemberAuth = await signInWithEmailAndPassword(config, nonMemberCredentials); + const readResult = await readUnknownIngredientQueue(config, householdId, nonMemberAuth.idToken); + + if (!isPermissionDeniedFirestoreResponse(readResult)) { + throw new Error('Non-member smoke check failed: expected PERMISSION_DENIED.', { + cause: { + actor: 'non-member', + householdId, + readResult, + }, + }); + } + + console.info('smoke_check_non_member_denied_ok', { + actor: nonMemberCredentials.email, + householdId, + status: readResult.status, + firestoreStatus: readResult.body?.error?.status, + }); +} + +function getCredentialsFromEnv(prefix) { + const email = requireEnv(`${prefix}_EMAIL`); + const password = requireEnv(`${prefix}_PASSWORD`); + + return { + email, + password, + }; +} + +async function main() { + const configFromFile = await readAppFirebaseConfig(); + const config = { + apiKey: toNonEmptyString(process.env.SMOKE_FIREBASE_API_KEY) ?? configFromFile.apiKey, + projectId: toNonEmptyString(process.env.SMOKE_FIREBASE_PROJECT_ID) ?? configFromFile.projectId, + databaseId: toNonEmptyString(process.env.SMOKE_FIREBASE_DATABASE_ID) ?? configFromFile.databaseId, + }; + + if (!config.apiKey || !config.projectId || !config.databaseId) { + throw new Error('Missing Firebase runtime config required for smoke checks.', { + cause: { + apiKeyConfigured: Boolean(config.apiKey), + projectIdConfigured: Boolean(config.projectId), + databaseIdConfigured: Boolean(config.databaseId), + }, + }); + } + + const householdId = requireEnv('SMOKE_OWNER_HOUSEHOLD_ID'); + const ownerCredentials = getCredentialsFromEnv('SMOKE_OWNER'); + const nonMemberCredentials = getCredentialsFromEnv('SMOKE_NON_MEMBER'); + + console.info('smoke_check_started', { + databaseId: config.databaseId, + projectId: config.projectId, + }); + + await assertOwnerCanReadQueue(config, householdId, ownerCredentials); + await assertNonMemberIsDenied(config, householdId, nonMemberCredentials); + + console.info('smoke_check_completed', { + databaseId: config.databaseId, + projectId: config.projectId, + }); +} + +main().catch((error) => { + console.error('smoke_check_failed', { + message: error instanceof Error ? error.message : String(error), + cause: error instanceof Error ? error.cause : null, + }); + process.exitCode = 1; +}); diff --git a/src/App.tsx b/src/App.tsx index 42b6828..642f9e1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useState } from 'react'; -import { ChefHat, User, LogIn, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react'; +import { ChefHat, User, LogIn, Loader2, AlertCircle, CheckCircle2, Globe2, Mail } from 'lucide-react'; import { onAuthStateChanged, User as FirebaseUser } from 'firebase/auth'; import { collection, doc, + getDoc, onSnapshot, orderBy, query, @@ -13,24 +14,55 @@ import { import OwnerView from './components/OwnerView'; import CookView from './components/CookView'; import { auth, db, loginWithGoogle, logout } from './firebase'; -import { InventoryItem, InventoryStatus, MealPlan, PantryLog, Role, UiLanguage } from './types'; +import { InventoryItem, InventoryStatus, MealPlan, PantryLog, Role, UiLanguage, UnknownIngredientQueueItem } from './types'; import { toUserFacingError } from './utils/error'; import { addInventoryItem, - addUnlistedItemWithLog, clearAnomaly, deleteInventoryItem, + dismissUnknownIngredientQueueItem, + promoteUnknownIngredientQueueItem, + queueUnknownIngredient, updateInventoryStatusWithLog, } from './services/inventoryService'; import { upsertMealField } from './services/mealService'; import { HouseholdData, resolveOrCreateHousehold } from './services/householdService'; import { getAppCopy } from './i18n/copy'; +import firebaseConfig from '../firebase-applet-config.json'; +import { + buildUnknownQueueTargetFingerprint, + classifyHouseholdMembershipProbe, + getUnknownQueueLoadErrorMessage, + HouseholdMembershipProbeResult, + isFirestoreFailedPreconditionError, + sortUnknownIngredientQueueItemsByCreatedAt, + toFirestoreListenerErrorInfo, +} from './utils/unknownQueue'; interface UiFeedback { kind: 'success' | 'error'; message: string; } +function resolveAppBuildId(): string { + const viteEnv = (import.meta as ImportMeta & { env?: Record }).env; + const explicitBuildId = viteEnv?.VITE_BUILD_ID; + if (typeof explicitBuildId === 'string' && explicitBuildId.trim().length > 0) { + return explicitBuildId.trim(); + } + + const commitBuildId = viteEnv?.VITE_VERCEL_GIT_COMMIT_SHA; + if (typeof commitBuildId === 'string' && commitBuildId.trim().length > 0) { + return commitBuildId.trim(); + } + + return 'dev-local'; +} + +function appendBuildIdToDiagnosticMessage(message: string, buildId: string): string { + return `${message} [build:${buildId}]`; +} + export default function App() { const [user, setUser] = useState(null); const [isAuthReady, setIsAuthReady] = useState(false); @@ -43,6 +75,7 @@ export default function App() { const [inventory, setInventory] = useState([]); const [meals, setMeals] = useState>({}); const [logs, setLogs] = useState([]); + const [unknownIngredientQueue, setUnknownIngredientQueue] = useState([]); const [inviteEmail, setInviteEmail] = useState(''); const [isInviting, setIsInviting] = useState(false); @@ -52,9 +85,10 @@ export default function App() { const cookLanguage: UiLanguage = householdData?.cookLanguage ?? 'hi'; const activeLanguage: UiLanguage = isOwner ? ownerLanguage : cookLanguage; const appCopy = getAppCopy(activeLanguage); + const appBuildId = resolveAppBuildId(); 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`; + const shellMainClass = `${shellWidthClass} mx-auto px-4 pb-24 pt-6 md:px-6`; useEffect(() => { const unsubscribe = onAuthStateChanged(auth, (currentUser) => { @@ -80,9 +114,12 @@ export default function App() { let inventoryUnsub: Unsubscribe | null = null; let mealsUnsub: Unsubscribe | null = null; let logsUnsub: Unsubscribe | null = null; + let unknownQueueUnsub: Unsubscribe | null = null; + let unknownQueueFallbackUnsub: Unsubscribe | null = null; let hasLoadedHousehold = false; let hasLoadedInventory = false; let hasLoadedMeals = false; + let hasLoadedUnknownQueue = false; let hasResolvedInitialView = false; const markInitialViewReady = (): void => { @@ -90,7 +127,7 @@ export default function App() { return; } - if (hasLoadedHousehold && hasLoadedInventory && hasLoadedMeals) { + if (hasLoadedHousehold && hasLoadedInventory && hasLoadedMeals && hasLoadedUnknownQueue) { hasResolvedInitialView = true; setIsDataLoaded(true); setIsAuthReady(true); @@ -181,6 +218,140 @@ export default function App() { setUiFeedback({ kind: 'error', message: 'Failed to load activity logs.' }); }, ); + + const handleUnknownQueueLoaded = (items: UnknownIngredientQueueItem[]): void => { + setUnknownIngredientQueue(items); + hasLoadedUnknownQueue = true; + markInitialViewReady(); + }; + + const unknownQueuePath = `households/${resolved.householdId}/unknownIngredientQueue`; + const targetFingerprint = buildUnknownQueueTargetFingerprint({ + projectId: firebaseConfig.projectId, + databaseId: firebaseConfig.firestoreDatabaseId, + householdId: resolved.householdId, + }); + + const membershipProbeSnapshot = await getDoc(doc(db, 'households', resolved.householdId)); + const membershipProbeData = membershipProbeSnapshot.exists() + ? (membershipProbeSnapshot.data() as HouseholdData) + : null; + const membershipProbeResult: HouseholdMembershipProbeResult = classifyHouseholdMembershipProbe({ + householdExists: membershipProbeSnapshot.exists(), + householdOwnerId: membershipProbeData?.ownerId ?? null, + householdCookEmail: membershipProbeData?.cookEmail ?? null, + userUid: user.uid, + userEmail: user.email ?? null, + }); + + console.info('unknown_queue_runtime_target', { + projectId: firebaseConfig.projectId, + databaseId: firebaseConfig.firestoreDatabaseId, + buildId: appBuildId, + householdId: resolved.householdId, + uid: user.uid, + email: user.email ?? null, + path: unknownQueuePath, + targetFingerprint, + membershipProbeResult, + }); + + const subscribeUnknownQueueFallback = (): void => { + if (unknownQueueFallbackUnsub !== null) { + return; + } + + unknownQueueFallbackUnsub = onSnapshot( + collection(db, `households/${resolved.householdId}/unknownIngredientQueue`), + (snapshot) => { + const queueItems = snapshot.docs.map((queueDoc) => ({ + id: queueDoc.id, + ...(queueDoc.data() as Omit), + })); + handleUnknownQueueLoaded(sortUnknownIngredientQueueItemsByCreatedAt(queueItems)); + }, + (error) => { + const parsedError = toFirestoreListenerErrorInfo(error); + console.error('unknown_queue_snapshot_fallback_failed', { + error, + householdId: resolved.householdId, + code: parsedError.code, + message: parsedError.message, + projectId: firebaseConfig.projectId, + databaseId: firebaseConfig.firestoreDatabaseId, + buildId: appBuildId, + uid: user.uid, + email: user.email ?? null, + path: unknownQueuePath, + targetFingerprint, + membershipProbeResult, + }); + setUiFeedback({ + kind: 'error', + message: appendBuildIdToDiagnosticMessage( + getUnknownQueueLoadErrorMessage(parsedError, membershipProbeResult), + appBuildId, + ), + }); + hasLoadedUnknownQueue = true; + markInitialViewReady(); + }, + ); + }; + + unknownQueueUnsub = onSnapshot( + query(collection(db, `households/${resolved.householdId}/unknownIngredientQueue`), orderBy('createdAt', 'desc')), + (snapshot) => { + const queueItems = snapshot.docs.map((queueDoc) => ({ + id: queueDoc.id, + ...(queueDoc.data() as Omit), + })); + handleUnknownQueueLoaded(queueItems); + }, + (error) => { + const parsedError = toFirestoreListenerErrorInfo(error); + console.error('unknown_queue_snapshot_failed', { + error, + householdId: resolved.householdId, + code: parsedError.code, + message: parsedError.message, + projectId: firebaseConfig.projectId, + databaseId: firebaseConfig.firestoreDatabaseId, + buildId: appBuildId, + uid: user.uid, + email: user.email ?? null, + path: unknownQueuePath, + targetFingerprint, + membershipProbeResult, + }); + + if (isFirestoreFailedPreconditionError(parsedError)) { + if (unknownQueueUnsub !== null) { + unknownQueueUnsub(); + unknownQueueUnsub = null; + } + setUiFeedback({ + kind: 'error', + message: appendBuildIdToDiagnosticMessage( + getUnknownQueueLoadErrorMessage(parsedError, membershipProbeResult), + appBuildId, + ), + }); + subscribeUnknownQueueFallback(); + return; + } + + setUiFeedback({ + kind: 'error', + message: appendBuildIdToDiagnosticMessage( + getUnknownQueueLoadErrorMessage(parsedError, membershipProbeResult), + appBuildId, + ), + }); + hasLoadedUnknownQueue = true; + markInitialViewReady(); + }, + ); } catch (error) { console.error('household_initialize_failed', { error, userId: user.uid }); setUiFeedback({ kind: 'error', message: 'Failed to initialize household data.' }); @@ -203,8 +374,14 @@ export default function App() { if (logsUnsub) { logsUnsub(); } + if (unknownQueueUnsub) { + unknownQueueUnsub(); + } + if (unknownQueueFallbackUnsub) { + unknownQueueFallbackUnsub(); + } }; - }, [user]); + }, [user, appBuildId]); const handleUpdateInventory = async (id: string, newStatus: InventoryStatus, requestedQuantity?: string): Promise => { if (!user || !householdId) { @@ -262,7 +439,7 @@ export default function App() { } }; - const handleAddUnlistedItem = async ( + const handleQueueUnknownIngredient = async ( name: string, status: InventoryStatus, category: string, @@ -273,7 +450,7 @@ export default function App() { } try { - await addUnlistedItemWithLog({ + await queueUnknownIngredient({ db, householdId, name, @@ -282,10 +459,48 @@ export default function App() { requestedQuantity, role, }); - setUiFeedback({ kind: 'success', message: 'Added new requested ingredient.' }); + setUiFeedback({ kind: 'success', message: 'Queued unknown ingredient for owner review.' }); } catch (error) { - console.error('inventory_add_unlisted_failed', { error, householdId, name, status, category, requestedQuantity }); - setUiFeedback({ kind: 'error', message: toUserFacingError(error, 'Could not add requested ingredient.') }); + console.error('unknown_ingredient_queue_add_failed', { error, householdId, name, status, category, requestedQuantity }); + setUiFeedback({ kind: 'error', message: toUserFacingError(error, 'Could not queue unknown ingredient.') }); + } + }; + + const handlePromoteUnknownIngredient = async (queueItem: UnknownIngredientQueueItem): Promise => { + if (!user || !householdId) { + return; + } + + try { + await promoteUnknownIngredientQueueItem({ + db, + householdId, + queueItem, + role, + }); + setUiFeedback({ kind: 'success', message: 'Queued ingredient promoted to pantry.' }); + } catch (error) { + console.error('unknown_ingredient_promote_failed', { error, householdId, queueItemId: queueItem.id }); + setUiFeedback({ kind: 'error', message: toUserFacingError(error, 'Could not promote queued ingredient.') }); + } + }; + + const handleDismissUnknownIngredient = async (queueItem: UnknownIngredientQueueItem): Promise => { + if (!user || !householdId) { + return; + } + + try { + await dismissUnknownIngredientQueueItem({ + db, + householdId, + queueItem, + role, + }); + setUiFeedback({ kind: 'success', message: 'Queued ingredient dismissed.' }); + } catch (error) { + console.error('unknown_ingredient_dismiss_failed', { error, householdId, queueItemId: queueItem.id }); + setUiFeedback({ kind: 'error', message: toUserFacingError(error, 'Could not dismiss queued ingredient.') }); } }; @@ -440,6 +655,9 @@ export default function App() {
+
+ Build {appBuildId} +
{isOwner ? : } {isOwner ? appCopy.ownerRole : appCopy.cookRole} @@ -472,71 +690,96 @@ export default function App() { {role === 'owner' && householdData && (
-
-
-

{appCopy.householdSettings}

-

- {householdData.cookEmail - ? `Cook access granted to: ${householdData.cookEmail}` - : appCopy.inviteCookHint} -

-
- - +
+
+
+

{appCopy.householdSettings}

+

{appCopy.householdSettings}

+

{appCopy.householdSettingsHelper}

-
-
{householdData.cookEmail ? ( - - ) : ( -
- 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" - /> - +
+ + {householdData.cookEmail}
- )} + ) : null}
-
+ +
+
+
+ + {appCopy.languageProfiles} +
+
+ + +
+
+ +
+
+
+ + {appCopy.cookAccess} +
+

{householdData.cookEmail ? householdData.cookEmail : appCopy.inviteCookHint}

+
+ +
+ {householdData.cookEmail ? ( + + ) : ( +
+ setInviteEmail(event.target.value)} + className="w-full rounded-xl border border-stone-300 bg-white px-3 py-3 text-sm text-stone-700 outline-none transition focus:border-orange-500 focus:ring-2 focus:ring-orange-100 sm:min-w-[17rem]" + /> + +
+ )} +
+
+
+
)} @@ -555,6 +798,9 @@ export default function App() { onDeleteInventoryItem={handleDeleteInventoryItem} onClearAnomaly={handleClearAnomaly} logs={logs} + unknownIngredientQueue={unknownIngredientQueue} + onPromoteUnknownIngredient={handlePromoteUnknownIngredient} + onDismissUnknownIngredient={handleDismissUnknownIngredient} language={ownerLanguage} /> ) : ( @@ -562,7 +808,7 @@ export default function App() { meals={meals} inventory={inventory} onUpdateInventory={handleUpdateInventory} - onAddUnlistedItem={handleAddUnlistedItem} + onQueueUnknownIngredient={handleQueueUnknownIngredient} language={cookLanguage} /> )} diff --git a/src/components/CookView.tsx b/src/components/CookView.tsx index 4dccfaa..0cd47e5 100644 --- a/src/components/CookView.tsx +++ b/src/components/CookView.tsx @@ -3,13 +3,14 @@ import { Sun, Moon, AlertCircle, CheckCircle2, Search, Mic, Info, ShoppingCart, import { MealPlan, InventoryItem, InventoryStatus, UiLanguage } from '../types'; import { parseCookVoiceInput } from '../services/ai'; import { getLocalDateKey } from '../utils/date'; -import { getCookCopy } from '../i18n/copy'; +import { getIngredientNativeContextLabel, resolveInventoryItemVisual } from '../utils/ingredientVisuals'; +import { getCookCopy, getInventoryCopy } 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; + onQueueUnknownIngredient: (name: string, status: InventoryStatus, category: string, requestedQuantity?: string) => void; language: UiLanguage; } @@ -23,17 +24,11 @@ const DICT = { pantryCheck: "Pantry Check", pantryDesc: "Mark items that are running low or finished.", search: "Search ingredients...", - inStock: "Full", - low: "Low", - out: "Empty", voicePrompt: "Tell AI what's finished...", voiceBtn: "Update", notPlanned: "Not planned", success: "Updated successfully!", processing: "Processing...", - onList: "On List", - addNote: "Add Note", - save: "Save", }, hi: { todayMenu: "आज का मेनू", @@ -44,21 +39,15 @@ const DICT = { pantryCheck: "राशन चेक करें", pantryDesc: "जो सामान कम है या खत्म हो गया है, उसे मार्क करें।", search: "सामान खोजें...", - inStock: "पूरा है", - low: "कम है", - out: "खत्म", voicePrompt: "AI को बताएं क्या खत्म हुआ...", voiceBtn: "अपडेट करें", notPlanned: "तय नहीं है", success: "अपडेट हो गया!", processing: "प्रोसेस हो रहा है...", - onList: "सूची में है", - addNote: "नोट लिखें", - save: "सेव", } }; -export default function CookView({ meals, inventory, onUpdateInventory, onAddUnlistedItem, language }: Props) { +export default function CookView({ meals, inventory, onUpdateInventory, onQueueUnknownIngredient, language }: Props) { const [searchTerm, setSearchTerm] = useState(''); const [aiInput, setAiInput] = useState(''); const [isProcessing, setIsProcessing] = useState(false); @@ -66,10 +55,12 @@ export default function CookView({ meals, inventory, onUpdateInventory, onAddUnl const [errorMessage, setErrorMessage] = useState(null); const [editingNoteId, setEditingNoteId] = useState(null); const [noteValue, setNoteValue] = useState(''); + const [failedImageIds, setFailedImageIds] = useState>({}); const lang = language; const t = DICT[lang]; const copy = getCookCopy(lang); + const inventoryCopy = getInventoryCopy(lang); const today = getLocalDateKey(new Date()); const todaysMeals = meals[today] || { morning: t.notPlanned, evening: t.notPlanned, notes: undefined, leftovers: undefined }; @@ -94,11 +85,7 @@ export default function CookView({ meals, inventory, onUpdateInventory, onAddUnl setNoteValue(''); } - let statusText = ''; - if (status === 'in-stock') statusText = t.inStock; - if (status === 'low') statusText = t.low; - if (status === 'out') statusText = t.out; - + const statusText = inventoryCopy.statusLabels[status]; const displayName = lang === 'hi' && itemHiName ? itemHiName : itemName; showToast(`${displayName} ➔ ${statusText}`); }; @@ -107,7 +94,20 @@ export default function CookView({ meals, inventory, onUpdateInventory, onAddUnl onUpdateInventory(id, status, noteValue); setEditingNoteId(null); setNoteValue(''); - showToast(lang === 'hi' ? 'नोट सेव हो गया' : 'Note saved'); + showToast(inventoryCopy.noteSaved); + }; + + const handleVisualImageError = (itemId: string): void => { + setFailedImageIds((current) => { + if (current[itemId]) { + return current; + } + + return { + ...current, + [itemId]: true, + }; + }); }; const handleAiSubmit = async (e: React.FormEvent) => { @@ -132,7 +132,7 @@ export default function CookView({ meals, inventory, onUpdateInventory, onAddUnl }); result.unlistedItems.forEach(item => { - onAddUnlistedItem(item.name, item.status as InventoryStatus, item.category, item.requestedQuantity); + onQueueUnknownIngredient(item.name, item.status as InventoryStatus, item.category, item.requestedQuantity); updatedCount++; }); @@ -304,76 +304,111 @@ export default function CookView({ meals, inventory, onUpdateInventory, onAddUnl
-
- {filteredInventory.map((item) => ( + {filteredInventory.length === 0 ? ( +
+ {lang === 'hi' ? 'कोई आइटम नहीं मिला। दूसरा नाम खोजें।' : 'No matching items found. Try another search term.'} +
+ ) : ( +
+ {filteredInventory.map((item) => { + const visual = resolveInventoryItemVisual(item); + const nativeContext = getIngredientNativeContextLabel(item, visual); + const showImage = visual.imageUrl !== null && failedImageIds[item.id] !== true; + + return (
- {item.icon} + {showImage ? ( + {visual.altText} handleVisualImageError(item.id)} + /> + ) : ( + + {visual.fallbackIcon} + + )}

{lang === 'hi' && item.nameHi ? item.nameHi : item.name}

{lang === 'hi' &&

{item.name}

} + {nativeContext !== null && (lang === 'en' || item.nameHi === undefined) ? ( + + {nativeContext} + + ) : null}
{(item.status === 'low' || item.status === 'out') && (
- {t.onList} + {inventoryCopy.onGroceryList}
)}
-
+
{(item.status === 'low' || item.status === 'out') && (
{editingNoteId === item.id ? ( -
+
setNoteValue(e.target.value)} - placeholder={lang === 'hi' ? 'कितना चाहिए? (उदा: 2kg)' : 'Quantity? (e.g. 2kg)'} + placeholder={inventoryCopy.quantityPlaceholder} className="min-w-0 flex-1 rounded-xl border border-stone-300 px-3 py-2 text-sm outline-none transition focus:border-orange-500 focus:ring-2 focus:ring-orange-100" autoFocus /> @@ -389,18 +424,20 @@ export default function CookView({ meals, inventory, onUpdateInventory, onAddUnl {item.requestedQuantity ? ( - {lang === 'hi' ? 'नोट:' : 'Note:'} {item.requestedQuantity} + {inventoryCopy.noteLabel}: {item.requestedQuantity} ) : ( - {t.addNote} + {inventoryCopy.addNote} )} )}
)}
- ))} -
+ ); + })} +
+ )}
diff --git a/src/components/GroceryList.tsx b/src/components/GroceryList.tsx index 3cfc11f..3717339 100644 --- a/src/components/GroceryList.tsx +++ b/src/components/GroceryList.tsx @@ -1,15 +1,48 @@ import React from 'react'; import { ShoppingCart, CheckCircle2 } from 'lucide-react'; -import { InventoryItem, InventoryStatus } from '../types'; +import { InventoryItem, InventoryStatus, UiLanguage } from '../types'; +import { getIngredientNativeContextLabel, resolveInventoryItemVisual } from '../utils/ingredientVisuals'; import { getPantryCategoryLabel } from '../utils/pantryCategory'; +import { getInventoryCopy } from '../i18n/copy'; interface Props { inventory: InventoryItem[]; onUpdateInventory: (id: string, status: InventoryStatus) => void; + language: UiLanguage; } -export default function GroceryList({ inventory, onUpdateInventory }: Props) { +export default function GroceryList({ inventory, onUpdateInventory, language }: Props) { + const [failedImageIds, setFailedImageIds] = React.useState>({}); const lowStockItems = inventory.filter((item) => item.status === 'low' || item.status === 'out'); + const inventoryCopy = getInventoryCopy(language); + const content = language === 'hi' + ? { + tag: 'ओनर किराना सूची', + title: 'किराना सूची', + helper: 'जो सामान कम हो रहा है या खत्म हो गया है, उसे यहां ट्रैक करें और भरने के बाद अपडेट करें।', + emptyTitle: 'अभी सब ठीक है', + emptyHelper: 'जब कोई आइटम कम होगा या खत्म होगा, वह यहां दिखेगा।', + } + : { + tag: 'Owner Grocery List', + title: 'Grocery List', + helper: 'Track items that are running low or out of stock, then update them once restocked.', + emptyTitle: 'All caught up', + emptyHelper: 'Nothing is currently running low. When pantry items drop to running low or out of stock, they will appear here.', + }; + + const handleVisualImageError = (itemId: string): void => { + setFailedImageIds((current) => { + if (current[itemId]) { + return current; + } + + return { + ...current, + [itemId]: true, + }; + }); + }; return (
@@ -18,11 +51,9 @@ export default function GroceryList({ inventory, onUpdateInventory }: Props) {
-

Owner Grocery List

-

Grocery List

-

- Track running-low items and mark them bought when restocked. -

+

{content.tag}

+

{content.title}

+

{content.helper}

@@ -31,18 +62,21 @@ export default function GroceryList({ inventory, onUpdateInventory }: Props) {
-

All caught up

-

- Nothing is currently running low. When pantry items drop to low or out of stock, they will appear here. -

+

{content.emptyTitle}

+

{content.emptyHelper}

) : (
    - {lowStockItems.map((item) => ( + {lowStockItems.map((item) => { + const visual = resolveInventoryItemVisual(item); + const nativeContext = getIngredientNativeContextLabel(item, visual); + const showImage = visual.imageUrl !== null && failedImageIds[item.id] !== true; + + return (
  • - {item.icon} + {showImage ? ( + {visual.altText} handleVisualImageError(item.id)} + /> + ) : ( + + {visual.fallbackIcon} + + )} {item.name} @@ -62,21 +113,30 @@ export default function GroceryList({ inventory, onUpdateInventory }: Props) { )}
    -

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

    + {getPantryCategoryLabel(item.category)} • {inventoryCopy.statusLabels[item.status]}

  • - ))} + ); + })}
)} diff --git a/src/components/MealPlanner.tsx b/src/components/MealPlanner.tsx index b88f79c..e0d4f12 100644 --- a/src/components/MealPlanner.tsx +++ b/src/components/MealPlanner.tsx @@ -31,6 +31,18 @@ function getMealDetailValue(meal: MealPlan | undefined, field: MealDetailField): return meal[field] ?? ''; } +function formatWeekRangeLabel(weekDays: Date[]): string { + return `${weekDays[0].toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${weekDays[6].toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`; +} + +function formatWeekdayLabel(day: Date): string { + return day.toLocaleDateString('en-US', { weekday: 'short' }); +} + +function formatMonthDayLabel(day: Date): string { + return day.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + export default function MealPlanner({ meals, onUpdateMeal }: Props) { const [currentDate, setCurrentDate] = useState(new Date()); const [detailDrafts, setDetailDrafts] = useState({}); @@ -50,6 +62,7 @@ export default function MealPlanner({ meals, onUpdateMeal }: Props) { }; const weekDays = getWeekDays(currentDate); + const weekRangeLabel = formatWeekRangeLabel(weekDays); const prevWeek = (): void => { const newDate = new Date(currentDate); @@ -125,26 +138,23 @@ export default function MealPlanner({ meals, onUpdateMeal }: Props) { return (
-
+

Weekly Meal Plan

-

- {weekDays[0].toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} -{' '} - {weekDays[6].toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} -

+

{weekRangeLabel}

-
+
{weekDays.map((day, index) => { const dateStr = getLocalDateKey(day); const isToday = dateStr === todayDateKey; @@ -177,9 +187,10 @@ export default function MealPlanner({ meals, onUpdateMeal }: Props) { isToday ? 'text-orange-700' : 'text-stone-500' }`} > - {day.toLocaleDateString('en-US', { weekday: 'short' })} + {formatWeekdayLabel(day)}

{day.getDate()}

+

{formatMonthDayLabel(day)}

{isToday ? ( @@ -189,7 +200,7 @@ export default function MealPlanner({ meals, onUpdateMeal }: Props) {
-
+
Breakfast / Lunch @@ -199,11 +210,11 @@ export default function MealPlanner({ meals, onUpdateMeal }: Props) { onChange={(event) => handleMealChange(dateStr, 'morning', event.target.value)} data-testid={`meal-day-${index}-morning`} placeholder="Plan morning/lunch..." - className="min-h-32 w-full resize-y rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-[15px] leading-6 text-stone-800 outline-none transition focus:border-orange-400 focus:bg-white focus:ring-2 focus:ring-orange-200" + className="mt-3 min-h-28 w-full resize-y rounded-2xl border border-stone-200 bg-white px-4 py-3.5 text-[15px] leading-6 text-stone-800 outline-none transition focus:border-orange-400 focus:ring-2 focus:ring-orange-200" />
-
+
Dinner @@ -213,7 +224,7 @@ export default function MealPlanner({ meals, onUpdateMeal }: Props) { onChange={(event) => handleMealChange(dateStr, 'evening', event.target.value)} data-testid={`meal-day-${index}-evening`} placeholder="Dinner" - className="min-h-32 w-full resize-y rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-[15px] leading-6 text-stone-800 outline-none transition focus:border-orange-400 focus:bg-white focus:ring-2 focus:ring-orange-200" + className="mt-3 min-h-28 w-full resize-y rounded-2xl border border-stone-200 bg-white px-4 py-3.5 text-[15px] leading-6 text-stone-800 outline-none transition focus:border-orange-400 focus:ring-2 focus:ring-orange-200" />
@@ -224,7 +235,7 @@ export default function MealPlanner({ meals, onUpdateMeal }: Props) {

Details

-

Add leftovers and notes here. Changes save after a short pause or when you leave the field.

+

Add leftovers and notes here. Changes save after a short pause or when you leave the field.

diff --git a/src/components/OwnerView.tsx b/src/components/OwnerView.tsx index d9ba0fd..2aa54c6 100644 --- a/src/components/OwnerView.tsx +++ b/src/components/OwnerView.tsx @@ -1,10 +1,22 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { CalendarDays, ShoppingCart, Package } from 'lucide-react'; -import { MealPlan, InventoryItem, InventoryStatus, PantryLog, UiLanguage } from '../types'; +import { MealPlan, InventoryItem, InventoryStatus, PantryLog, UiLanguage, UnknownIngredientQueueItem } from '../types'; import MealPlanner from './MealPlanner'; import GroceryList from './GroceryList'; import Pantry from './Pantry'; -import { getOwnerCopy } from '../i18n/copy'; +import { getInventoryCopy, getOwnerCopy } from '../i18n/copy'; + +type OwnerTab = 'meals' | 'grocery' | 'pantry'; + +const ownerTabs: OwnerTab[] = ['meals', 'grocery', 'pantry']; + +function getOwnerTabId(tab: OwnerTab): string { + return `owner-tab-${tab}`; +} + +function getOwnerPanelId(tab: OwnerTab): string { + return `owner-panel-${tab}`; +} interface Props { meals: Record; @@ -15,30 +27,103 @@ interface Props { onDeleteInventoryItem: (id: string) => void; onClearAnomaly: (id: string) => void; logs: PantryLog[]; + unknownIngredientQueue: UnknownIngredientQueueItem[]; + onPromoteUnknownIngredient: (queueItem: UnknownIngredientQueueItem) => void; + onDismissUnknownIngredient: (queueItem: UnknownIngredientQueueItem) => void; language: UiLanguage; } -export default function OwnerView({ meals, onUpdateMeal, inventory, onAddInventoryItem, onUpdateInventory, onDeleteInventoryItem, onClearAnomaly, logs, language }: Props) { - const [activeTab, setActiveTab] = useState<'meals' | 'grocery' | 'pantry'>('meals'); +export default function OwnerView({ meals, onUpdateMeal, inventory, onAddInventoryItem, onUpdateInventory, onDeleteInventoryItem, onClearAnomaly, logs, unknownIngredientQueue, onPromoteUnknownIngredient, onDismissUnknownIngredient, language }: Props) { + const [activeTab, setActiveTab] = useState('meals'); + const tabRefs = useRef>({ + meals: null, + grocery: null, + pantry: null, + }); const copy = getOwnerCopy(language); + const inventoryCopy = getInventoryCopy(language); + const groceryPendingCount = inventory.filter((item) => item.status === 'low' || item.status === 'out').length; + const pantryAnomalyCount = inventory.filter((item) => item.verificationNeeded === true).length; + const unknownQueueOpenCount = unknownIngredientQueue.filter((item) => item.status === 'open').length; + const pantryAttentionCount = pantryAnomalyCount + unknownQueueOpenCount; const panelClassName = 'min-h-[600px] rounded-[28px] border border-stone-200/80 bg-stone-50/60 p-3 shadow-sm md:p-4'; - const tabButtonClass = (tab: 'meals' | 'grocery' | 'pantry'): string => - `flex min-w-0 items-center justify-center gap-2 rounded-[20px] px-4 py-3 text-sm font-semibold transition-all ${ + const tabButtonClass = (tab: OwnerTab): string => + `flex min-w-0 items-center justify-center gap-2 rounded-[20px] border px-4 py-3 text-sm font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-300 md:px-5 ${ activeTab === tab - ? 'border border-orange-200 bg-orange-100 text-orange-800 shadow-sm' - : 'border border-transparent bg-transparent text-stone-500 hover:border-stone-200 hover:bg-white hover:text-stone-800' + ? 'border-orange-200 bg-white text-orange-800 shadow-sm ring-1 ring-orange-100' + : 'border-transparent bg-transparent text-stone-500 hover:border-stone-200 hover:bg-white/90 hover:text-stone-800' }`; + const counterClassName = (count: number): string => + `inline-flex min-w-7 items-center justify-center rounded-full px-2 py-0.5 text-xs font-bold ${ + count > 0 ? 'bg-orange-100 text-orange-700' : 'bg-stone-200 text-stone-500' + }`; + + const setAndFocusTab = (tab: OwnerTab): void => { + setActiveTab(tab); + tabRefs.current[tab]?.focus(); + }; + + const handleTabKeyDown = (tab: OwnerTab, event: React.KeyboardEvent): void => { + const currentIndex = ownerTabs.indexOf(tab); + let nextTab: OwnerTab | null = null; + + if (event.key === 'ArrowRight') { + nextTab = ownerTabs[(currentIndex + 1) % ownerTabs.length]; + } + + if (event.key === 'ArrowLeft') { + nextTab = ownerTabs[(currentIndex - 1 + ownerTabs.length) % ownerTabs.length]; + } + + if (event.key === 'Home') { + nextTab = ownerTabs[0]; + } + + if (event.key === 'End') { + nextTab = ownerTabs[ownerTabs.length - 1]; + } + + if (nextTab === null) { + return; + } + + event.preventDefault(); + setAndFocusTab(nextTab); + }; + + const renderPanelContent = (tab: OwnerTab): React.ReactNode => { + if (tab === 'meals') { + return ; + } + + if (tab === 'grocery') { + return ; + } + + return ( + + ); + }; return ( -
+
-
+

{copy.workspaceTag}

{copy.title}

-

- {copy.helper} -

+

{copy.helper}

@@ -49,30 +134,91 @@ export default function OwnerView({ meals, onUpdateMeal, inventory, onAddInvento
-
-
- - -
-
-
- {activeTab === 'meals' && } - {activeTab === 'grocery' && } - {activeTab === 'pantry' && } + {ownerTabs.map((tab) => ( + -
-
+ ))} +
); } diff --git a/src/components/Pantry.tsx b/src/components/Pantry.tsx index d259151..33afbbe 100644 --- a/src/components/Pantry.tsx +++ b/src/components/Pantry.tsx @@ -1,7 +1,9 @@ import React, { useState } from 'react'; import { AlertTriangle, CheckCircle2, Clock, History, Package, Plus, Search, Trash2 } from 'lucide-react'; -import { InventoryItem, InventoryStatus, PantryLog } from '../types'; +import { InventoryItem, InventoryStatus, PantryLog, Role, UiLanguage, UnknownIngredientQueueItem } from '../types'; import { generateId } from '../utils/id'; +import { getIngredientNativeContextLabel, resolveIngredientVisual, resolveInventoryItemVisual } from '../utils/ingredientVisuals'; +import { getInventoryCopy } from '../i18n/copy'; import { getPantryCategoryLabel, getPantryCategoryOptions, @@ -17,16 +19,139 @@ interface Props { onDeleteInventoryItem: (id: string) => void; onClearAnomaly: (id: string) => void; logs: PantryLog[]; + unknownIngredientQueue: UnknownIngredientQueueItem[]; + onPromoteUnknownIngredient: (queueItem: UnknownIngredientQueueItem) => void; + onDismissUnknownIngredient: (queueItem: UnknownIngredientQueueItem) => void; + language: UiLanguage; } const categoryOptions = getPantryCategoryOptions(); -export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventory, onDeleteInventoryItem, onClearAnomaly, logs }: Props) { +function getStatusSelectToneClass(status: InventoryStatus): string { + if (status === 'in-stock') { + return 'border-emerald-200 bg-emerald-50 text-emerald-700'; + } + + if (status === 'low') { + return 'border-yellow-200 bg-yellow-50 text-yellow-700'; + } + + return 'border-red-200 bg-red-50 text-red-700'; +} + +function getStatusTextToneClass(status: InventoryStatus): string { + if (status === 'in-stock') { + return 'text-emerald-600'; + } + + if (status === 'low') { + return 'text-yellow-600'; + } + + return 'text-red-600'; +} + +function getStatusIconToneClass(status: InventoryStatus): string { + if (status === 'in-stock') { + return 'bg-emerald-100 text-emerald-600'; + } + + if (status === 'low') { + return 'bg-yellow-100 text-yellow-600'; + } + + return 'bg-red-100 text-red-600'; +} + +function getRoleLabel(language: UiLanguage, role: Role): string { + if (language === 'hi') { + return role === 'owner' ? 'ओनर' : 'कुक'; + } + + return role === 'owner' ? 'Owner' : 'Cook'; +} + +export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventory, onDeleteInventoryItem, onClearAnomaly, logs, unknownIngredientQueue, onPromoteUnknownIngredient, onDismissUnknownIngredient, language }: Props) { const [newItemName, setNewItemName] = useState(''); const [newItemCategory, setNewItemCategory] = useState('spices'); const [newItemQuantity, setNewItemQuantity] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [viewMode, setViewMode] = useState<'inventory' | 'logs'>('inventory'); + const [failedImageIds, setFailedImageIds] = useState>({}); + const inventoryCopy = getInventoryCopy(language); + const content = language === 'hi' + ? { + workspaceTag: 'ओनर वर्कस्पेस', + title: 'पेंट्री मैनेजमेंट', + helper: 'इन्वेंटरी ट्रैक करें, विसंगतियां साफ करें, और गतिविधि लॉग्स देखें।', + inventoryView: 'इन्वेंटरी', + logsView: 'एक्टिविटी लॉग्स', + addTitle: 'नया सामान जोड़ें', + addHelper: 'मोबाइल पर फॉर्म कॉम्पैक्ट रखें और बड़ी स्क्रीन पर आराम से भरें।', + namePlaceholder: 'सामान का नाम (उदा: जीरा)', + defaultSizePlaceholder: 'डिफ़ॉल्ट मात्रा (उदा: 500g)', + addItem: 'आइटम जोड़ें', + inventoryTitle: 'इन्वेंटरी', + inventoryHelper: 'एक ही जगह से खोजें, अपडेट करें, वेरिफाई करें, और हटाएँ।', + searchPlaceholder: 'पेंट्री खोजें...', + nameColumn: 'नाम', + categoryColumn: 'कैटेगरी', + defaultSizeColumn: 'डिफ़ॉल्ट मात्रा', + statusColumn: 'स्थिति', + lastUpdatedColumn: 'आखिरी अपडेट', + actionsColumn: 'एक्शन', + mobileMetaLabel: 'आखिरी अपडेट', + updatedByPrefix: 'द्वारा', + clearWarning: 'चेतावनी साफ करें', + deleteItem: 'आइटम हटाएँ', + noItems: 'कोई आइटम नहीं मिला।', + queueTitle: 'अज्ञात सामग्री समीक्षा कतार', + queueHelper: 'कुक की नई अनमैच सामग्री रिक्वेस्ट यहां आएगी। पेंट्री में प्रमोट करें या खारिज करें।', + queueEmpty: 'समीक्षा के लिए कोई लंबित रिक्वेस्ट नहीं है।', + queueRequestedBy: 'रिक्वेस्ट', + queuePromote: 'प्रमोट करें', + queueDismiss: 'खारिज करें', + logsTitle: 'एक्टिविटी लॉग्स', + logsHelper: 'हाल के पेंट्री बदलाव, वेरिफिकेशन इवेंट्स, और स्टेटस अपडेट्स।', + noLogsTitle: 'अभी कोई एक्टिविटी लॉग नहीं है।', + noLogsHelper: 'पेंट्री आइटम में बदलाव यहां दिखाई देंगे।', + } + : { + workspaceTag: 'Owner Workspace', + title: 'Pantry Management', + helper: 'Track inventory, resolve anomalies, and review activity without losing the table workflow.', + inventoryView: 'Inventory', + logsView: 'Activity Logs', + addTitle: 'Add New Ingredient', + addHelper: 'Keep the form compact on mobile and open it up on larger screens.', + namePlaceholder: 'Ingredient name (e.g., Jeera)', + defaultSizePlaceholder: 'Default Size (e.g., 500g)', + addItem: 'Add Item', + inventoryTitle: 'Inventory', + inventoryHelper: 'Search, update, verify, and delete items from one place.', + searchPlaceholder: 'Search pantry...', + nameColumn: 'Name', + categoryColumn: 'Category', + defaultSizeColumn: 'Default Size', + statusColumn: 'Status', + lastUpdatedColumn: 'Last Updated', + actionsColumn: 'Actions', + mobileMetaLabel: 'Last updated', + updatedByPrefix: 'by', + clearWarning: 'Verify & Clear Warning', + deleteItem: 'Delete Item', + noItems: 'No items found.', + queueTitle: 'Unknown Ingredient Review Queue', + queueHelper: 'New unmatched ingredient requests from cook are collected here for owner review.', + queueEmpty: 'No pending unknown ingredient requests.', + queueRequestedBy: 'Requested', + queuePromote: 'Promote', + queueDismiss: 'Dismiss', + logsTitle: 'Activity Logs', + logsHelper: 'Recent pantry changes, verification events, and status updates.', + noLogsTitle: 'No activity logs yet.', + noLogsHelper: 'Changes to pantry items will appear here.', + }; const handleAddItem = (event: React.FormEvent): void => { event.preventDefault(); @@ -34,12 +159,17 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor return; } + const category = normalizePantryCategory(newItemCategory); + const visual = resolveIngredientVisual({ + name: newItemName.trim(), + category, + }); const newItem: InventoryItem = { id: generateId(), name: newItemName.trim(), - category: newItemCategory, + category, status: 'in-stock', - icon: '📦', + icon: visual.fallbackIcon, lastUpdated: new Date().toISOString(), updatedBy: 'owner', defaultQuantity: newItemQuantity || undefined, @@ -51,12 +181,30 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor }; const handleDeleteItem = (id: string): void => { + const confirmed = window.confirm('Delete this pantry item? This action cannot be undone.'); + if (!confirmed) { + return; + } onDeleteInventoryItem(id); }; + const handleVisualImageError = (itemId: string): void => { + setFailedImageIds((current) => { + if (current[itemId]) { + return current; + } + + return { + ...current, + [itemId]: true, + }; + }); + }; + const filteredInventory = inventory.filter( (item) => item.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (item.nameHi !== undefined && item.nameHi.includes(searchTerm)) || pantryCategoryMatchesSearch(item.category, searchTerm), ); @@ -70,6 +218,108 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor }); }; + const renderItemVisual = (item: InventoryItem): React.ReactNode => { + const visual = resolveInventoryItemVisual(item); + const showImage = visual.imageUrl !== null && failedImageIds[item.id] !== true; + + if (showImage) { + return ( + {visual.altText} handleVisualImageError(item.id)} + /> + ); + } + + return ( + + {visual.fallbackIcon} + + ); + }; + + const renderStatusSelect = (item: InventoryItem, testId?: string): React.ReactNode => ( + + ); + + const renderUpdatedMeta = (item: InventoryItem): React.ReactNode => { + if (!item.lastUpdated) { + return -; + } + + return ( +
+ + + {formatTime(item.lastUpdated)} + + + {content.updatedByPrefix} {item.updatedBy ? getRoleLabel(language, item.updatedBy) : '-'} + +
+ ); + }; + + const getNativeContext = (item: InventoryItem): { label: string | null; title: string | undefined } => { + const visual = resolveInventoryItemVisual(item); + return { + label: getIngredientNativeContextLabel(item, visual), + title: visual.catalogMatch?.canonicalName, + }; + }; + + const renderActionButtons = (item: InventoryItem, className: string, clearTestId?: string, deleteTestId?: string): React.ReactNode => ( +
+ {item.verificationNeeded ? ( + + ) : null} + +
+ ); + + const formatLogMessage = (log: PantryLog): string => { + const roleLabel = getRoleLabel(language, log.role); + + if (language === 'hi') { + return `${roleLabel} ने ${log.itemName} को`; + } + + return `${roleLabel} ${inventoryCopy.logMarkedAs} ${log.itemName} as`; + }; + + const openQueueItems = unknownIngredientQueue.filter((queueItem) => queueItem.status === 'open'); + return (
@@ -79,11 +329,9 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor
-

Owner Workspace

-

Pantry Management

-

- Track inventory, resolve anomalies, and review activity without losing the table workflow. -

+

{content.workspaceTag}

+

{content.title}

+

{content.helper}

@@ -95,7 +343,7 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor }`} data-testid="pantry-view-inventory" > - Inventory + {content.inventoryView}
@@ -115,13 +363,13 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor
-

Add New Ingredient

-

Keep the form compact on mobile and open it up on larger screens.

+

{content.addTitle}

+

{content.addHelper}

setNewItemName(event.target.value)} className="w-full rounded-xl border border-stone-300 bg-white px-4 py-3 text-sm outline-none transition focus:border-orange-500 focus:ring-2 focus:ring-orange-100" @@ -141,7 +389,7 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor setNewItemQuantity(event.target.value)} className="w-full rounded-xl border border-stone-300 bg-white px-4 py-3 text-sm outline-none transition focus:border-orange-500 focus:ring-2 focus:ring-orange-100" @@ -153,22 +401,70 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor data-testid="pantry-add-item" > - Add Item + {content.addItem}
+
+
+

{content.queueTitle}

+

{content.queueHelper}

+
+ {openQueueItems.length === 0 ? ( +

{content.queueEmpty}

+ ) : ( +
+ {openQueueItems.map((queueItem) => ( +
+
+
+

{queueItem.name}

+

{getPantryCategoryLabel(queueItem.category)}

+

+ {content.queueRequestedBy} {getRoleLabel(language, queueItem.createdBy)} • {formatTime(queueItem.createdAt)} +

+ {queueItem.requestedQuantity ? ( +

{queueItem.requestedQuantity}

+ ) : null} +
+ + {inventoryCopy.statusLabels[queueItem.requestedStatus]} + +
+
+ + +
+
+ ))} +
+ )} +
+
-

Inventory Table

-

Search, update, verify, and delete items from one place.

+

{content.inventoryTitle}

+

{content.inventoryHelper}

setSearchTerm(event.target.value)} className="w-full rounded-xl border border-stone-300 bg-white py-3 pl-10 pr-4 text-base outline-none shadow-sm transition focus:border-orange-500 focus:ring-2 focus:ring-orange-100" @@ -178,26 +474,99 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor
-
- + {filteredInventory.length === 0 ? ( +
+ {content.noItems} +
+ ) : ( + <> +
+ {filteredInventory.map((item) => { + const nativeContext = getNativeContext(item); + + return ( +
+
+ {renderItemVisual(item)} +
+
+
+

{item.name}

+ {nativeContext.label !== null ? ( + + {nativeContext.label} + + ) : null} +

{getPantryCategoryLabel(item.category)}

+
+ {item.defaultQuantity ? ( + + {item.defaultQuantity} + + ) : null} +
+ {item.verificationNeeded ? ( + + + {item.anomalyReason} + + ) : null} +
+
+ +
+
+

{content.statusColumn}

+ {renderStatusSelect(item, `pantry-mobile-status-${item.id}`)} +
+
+

{content.mobileMetaLabel}

+ {renderUpdatedMeta(item)} +
+
+ +
+ {renderActionButtons(item, 'flex flex-wrap gap-2', `pantry-mobile-clear-anomaly-${item.id}`, `pantry-mobile-delete-${item.id}`)} +
+
+ ); + })} +
+ +
+
- - - - - - + + + + + + - {filteredInventory.map((item) => ( + {filteredInventory.map((item) => { + const nativeContext = getNativeContext(item); + + return ( - + - ))} + ); + })}
NameCategoryDefault SizeStatusLast UpdatedActions{content.nameColumn}{content.categoryColumn}{content.defaultSizeColumn}{content.statusColumn}{content.lastUpdatedColumn}{content.actionsColumn}
- {item.icon} + {renderItemVisual(item)}
{item.name} + {nativeContext.label !== null ? ( + + {nativeContext.label} + + ) : null} {item.verificationNeeded ? ( @@ -210,112 +579,51 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor
{getPantryCategoryLabel(item.category)} {item.defaultQuantity || '-'} - - - {item.lastUpdated ? ( -
- - - {formatTime(item.lastUpdated)} - - by {item.updatedBy} -
- ) : ( - - - )} +
+ {renderStatusSelect(item, `pantry-status-${item.id}`)} +
{renderUpdatedMeta(item)} -
- {item.verificationNeeded ? ( - - ) : null} - -
+ {renderActionButtons(item, 'flex flex-wrap justify-end gap-2', `pantry-clear-anomaly-${item.id}`, `pantry-delete-${item.id}`)}
-
- {filteredInventory.length === 0 ? ( -
- No items found. -
- ) : null} +
+ + )}
) : (
-

Activity Logs

-

Recent pantry changes, verification events, and status updates.

+

{content.logsTitle}

+

{content.logsHelper}

{logs.length === 0 ? (
-

No activity logs yet.

-

Changes to pantry items will appear here.

+

{content.noLogsTitle}

+

{content.noLogsHelper}

) : (
    {logs.map((log) => (
  • -
    +

    - {log.role} marked {log.itemName} as{' '} - - {log.newStatus === 'in-stock' ? 'In Stock' : log.newStatus === 'low' ? 'Running Low' : 'Finished'} + {formatLogMessage(log)}{' '} + + {inventoryCopy.statusLabels[log.newStatus]} + {language === 'hi' ? {' मार्क किया'} : null}

    diff --git a/src/firebase.ts b/src/firebase.ts index 372e7ab..9c13536 100644 --- a/src/firebase.ts +++ b/src/firebase.ts @@ -1,5 +1,5 @@ import { initializeApp } from 'firebase/app'; -import { getAuth, GoogleAuthProvider, signInWithPopup, signOut } from 'firebase/auth'; +import { AuthError, getAuth, GoogleAuthProvider, signInWithPopup, signInWithRedirect, signOut } from 'firebase/auth'; import { getFirestore } from 'firebase/firestore'; import firebaseConfig from '../firebase-applet-config.json'; @@ -8,7 +8,53 @@ export const db = getFirestore(app, firebaseConfig.firestoreDatabaseId); export const auth = getAuth(app); export const googleProvider = new GoogleAuthProvider(); -export const loginWithGoogle = () => signInWithPopup(auth, googleProvider); +const REDIRECT_ELIGIBLE_POPUP_ERROR_CODES: ReadonlySet = new Set([ + 'auth/popup-blocked', + 'auth/popup-closed-by-user', + 'auth/cancelled-popup-request', + 'auth/operation-not-supported-in-this-environment', +]); + +function isAuthError(error: unknown): error is AuthError { + if (!error || typeof error !== 'object') { + return false; + } + + const candidate = error as Partial; + return typeof candidate.code === 'string'; +} + +function shouldFallbackToRedirect(error: AuthError): boolean { + return REDIRECT_ELIGIBLE_POPUP_ERROR_CODES.has(error.code); +} + +export const loginWithGoogle = async (): Promise => { + try { + await signInWithPopup(auth, googleProvider); + } catch (error) { + if (isAuthError(error) && shouldFallbackToRedirect(error)) { + // COOP/preview-related popup constraints can be non-fatal; redirect login remains valid. + console.warn('google_login_popup_failed_redirect_fallback', { + code: error.code, + message: error.message, + }); + await signInWithRedirect(auth, googleProvider); + return; + } + + if (isAuthError(error)) { + console.error('google_login_failed', { + code: error.code, + message: error.message, + }); + throw error; + } + + console.error('google_login_failed_unknown_error', { error }); + throw error; + } +}; + export const logout = () => signOut(auth); export enum OperationType { diff --git a/src/i18n/copy.ts b/src/i18n/copy.ts index c1ce069..03daa12 100644 --- a/src/i18n/copy.ts +++ b/src/i18n/copy.ts @@ -1,4 +1,4 @@ -import { UiLanguage } from '../types'; +import { InventoryStatus, UiLanguage } from '../types'; export interface AppCopy { ownerWorkspace: string; @@ -11,11 +11,14 @@ export interface AppCopy { accessRemoved: string; accessRemovedDetail: string; householdSettings: string; + householdSettingsHelper: string; inviteCookHint: string; inviteCookPlaceholder: string; invite: string; inviting: string; removeCook: string; + languageProfiles: string; + cookAccess: string; ownerLanguageLabel: string; cookLanguageLabel: string; ownerLanguageHint: string; @@ -42,6 +45,21 @@ export interface CookCopy { switchLabel: string; } +export interface InventoryCopy { + statusLabels: Record; + onGroceryList: string; + markRestocked: string; + addNote: string; + noteLabel: string; + saveNote: string; + quantityPlaceholder: string; + noteSaved: string; + groceryPendingCountLabel: string; + pantryAnomaliesCountLabel: string; + pantryReviewItemsCountLabel: string; + logMarkedAs: string; +} + const appCopyByLanguage: Record = { en: { ownerWorkspace: 'Owner workspace', @@ -54,11 +72,14 @@ const appCopyByLanguage: Record = { accessRemoved: 'Access Removed', accessRemovedDetail: 'Your owner removed this cook access. Sign out and ask the owner to invite you again.', householdSettings: 'Household Settings', + householdSettingsHelper: 'Manage access and language preferences without leaving the owner workspace.', inviteCookHint: 'Invite your cook to sync the pantry.', inviteCookPlaceholder: "Cook's Gmail address", invite: 'Invite', inviting: 'Inviting...', removeCook: 'Remove Cook', + languageProfiles: 'Language profiles', + cookAccess: 'Cook access', ownerLanguageLabel: 'Owner language profile', cookLanguageLabel: 'Cook language profile', ownerLanguageHint: 'English first with Hinglish helper is recommended for owner operations.', @@ -75,11 +96,14 @@ const appCopyByLanguage: Record = { accessRemoved: 'एक्सेस हटाया गया', accessRemovedDetail: 'ओनर ने यह कुक एक्सेस हटा दिया है। साइन आउट करें और फिर से इनवाइट के लिए कहें।', householdSettings: 'घर की सेटिंग्स', + householdSettingsHelper: 'ओनर वर्कस्पेस छोड़े बिना एक्सेस और भाषा पसंद संभालें।', inviteCookHint: 'पेंट्री सिंक के लिए अपने कुक को इनवाइट करें।', inviteCookPlaceholder: 'कुक का Gmail पता', invite: 'इनवाइट', inviting: 'इनवाइट भेज रहे हैं...', removeCook: 'कुक हटाएँ', + languageProfiles: 'भाषा प्रोफाइल', + cookAccess: 'कुक एक्सेस', ownerLanguageLabel: 'ओनर भाषा प्रोफाइल', cookLanguageLabel: 'कुक भाषा प्रोफाइल', ownerLanguageHint: 'ओनर काम के लिए English + Hinglish helper सबसे आसान रहता है।', @@ -129,6 +153,45 @@ const cookCopyByLanguage: Record = { }, }; +const inventoryCopyByLanguage: Record = { + en: { + statusLabels: { + 'in-stock': 'In Stock', + low: 'Running Low', + out: 'Out of Stock', + }, + onGroceryList: 'On List', + markRestocked: 'Mark Restocked', + addNote: 'Add Note', + noteLabel: 'Note', + saveNote: 'Save note', + quantityPlaceholder: 'Quantity? (e.g. 2kg)', + noteSaved: 'Note saved', + groceryPendingCountLabel: 'pending grocery items', + pantryAnomaliesCountLabel: 'pantry anomalies', + pantryReviewItemsCountLabel: 'pantry review items', + logMarkedAs: 'marked', + }, + hi: { + statusLabels: { + 'in-stock': 'स्टॉक में है', + low: 'कम हो रहा है', + out: 'खत्म हो गया', + }, + onGroceryList: 'सूची में है', + markRestocked: 'फिर से भर गया', + addNote: 'नोट जोड़ें', + noteLabel: 'नोट', + saveNote: 'नोट सेव करें', + quantityPlaceholder: 'कितना चाहिए? (उदा: 2kg)', + noteSaved: 'नोट सेव हो गया', + groceryPendingCountLabel: 'किराना आइटम लंबित', + pantryAnomaliesCountLabel: 'पेंट्री विसंगतियां', + pantryReviewItemsCountLabel: 'पेंट्री समीक्षा आइटम', + logMarkedAs: 'मार्क किया', + }, +}; + export function getAppCopy(language: UiLanguage): AppCopy { return appCopyByLanguage[language]; } @@ -140,3 +203,7 @@ export function getOwnerCopy(language: UiLanguage): OwnerCopy { export function getCookCopy(language: UiLanguage): CookCopy { return cookCopyByLanguage[language]; } + +export function getInventoryCopy(language: UiLanguage): InventoryCopy { + return inventoryCopyByLanguage[language]; +} diff --git a/src/services/inventoryService.ts b/src/services/inventoryService.ts index aa21156..a43e99c 100644 --- a/src/services/inventoryService.ts +++ b/src/services/inventoryService.ts @@ -6,9 +6,10 @@ import { updateDoc, writeBatch, } from 'firebase/firestore'; -import { InventoryItem, InventoryStatus, Role } from '../types'; +import { InventoryItem, InventoryStatus, Role, UnknownIngredientQueueItem } from '../types'; import { sanitizeFirestorePayload } from '../utils/firestorePayload'; import { generateId } from '../utils/id'; +import { resolveIngredientVisual } from '../utils/ingredientVisuals'; import { normalizePantryCategory } from '../utils/pantryCategory'; import { buildPantryLog } from './logService'; @@ -31,6 +32,31 @@ interface AddUnlistedItemWithLogInput { role: Role; } +interface QueueUnknownIngredientInput { + db: Firestore; + householdId: string; + name: string; + status: InventoryStatus; + category: string; + requestedQuantity?: string; + role: Role; +} + +interface ResolveUnknownIngredientQueueItemInput { + db: Firestore; + householdId: string; + queueItem: UnknownIngredientQueueItem; + role: Role; +} + +function getResolvedNameHi(currentNameHi: string | undefined, resolvedNativeName: string | undefined): string | undefined { + if (typeof currentNameHi === 'string' && currentNameHi.trim().length > 0) { + return currentNameHi; + } + + return resolvedNativeName; +} + function getInventoryAnomaly(item: InventoryItem, nextStatus: InventoryStatus): { verificationNeeded: boolean; anomalyReason?: string } { if (nextStatus !== 'out' || !item.lastUpdated || item.status !== 'in-stock') { return { verificationNeeded: false }; @@ -83,9 +109,19 @@ export async function updateInventoryStatusWithLog(input: UpdateInventoryWithLog } export async function addInventoryItem(db: Firestore, householdId: string, item: InventoryItem): Promise { + const category = normalizePantryCategory(item.category); + const visual = resolveIngredientVisual({ + name: item.name, + nameHi: item.nameHi, + category, + icon: item.icon, + }); + const normalizedItem: InventoryItem = { ...item, - category: normalizePantryCategory(item.category), + category, + icon: visual.fallbackIcon, + nameHi: getResolvedNameHi(item.nameHi, visual.catalogMatch?.nativeName), }; await setDoc( @@ -101,12 +137,18 @@ export async function deleteInventoryItem(db: Firestore, householdId: string, it export async function addUnlistedItemWithLog(input: AddUnlistedItemWithLogInput): Promise { const { db, householdId, name, status, category, requestedQuantity, role } = input; const timestampIso = new Date().toISOString(); + const normalizedCategory = normalizePantryCategory(category); + const visual = resolveIngredientVisual({ + name, + category: normalizedCategory, + }); const inventoryItem: InventoryItem = { id: generateId(), name, - category: normalizePantryCategory(category), + nameHi: getResolvedNameHi(undefined, visual.catalogMatch?.nativeName), + category: normalizedCategory, status, - icon: '🆕', + icon: visual.fallbackIcon, requestedQuantity, lastUpdated: timestampIso, updatedBy: role, @@ -132,6 +174,89 @@ export async function addUnlistedItemWithLog(input: AddUnlistedItemWithLogInput) await batch.commit(); } +export async function queueUnknownIngredient(input: QueueUnknownIngredientInput): Promise { + const { db, householdId, name, status, category, requestedQuantity, role } = input; + const queueId = generateId(); + const createdAt = new Date().toISOString(); + const normalizedCategory = normalizePantryCategory(category); + + const queueItem: UnknownIngredientQueueItem = { + id: queueId, + name, + status: 'open', + requestedStatus: status, + category: normalizedCategory, + requestedQuantity, + createdAt, + createdBy: role, + resolution: undefined, + resolvedAt: undefined, + resolvedBy: undefined, + promotedInventoryItemId: undefined, + }; + + await setDoc( + doc(db, `households/${householdId}/unknownIngredientQueue`, queueId), + sanitizeFirestorePayload(queueItem), + ); +} + +export async function dismissUnknownIngredientQueueItem(input: ResolveUnknownIngredientQueueItemInput): Promise { + const { db, householdId, queueItem, role } = input; + if (queueItem.status !== 'open') { + throw new Error(`Cannot dismiss queue item ${queueItem.id} because it is already resolved.`); + } + + await updateDoc(doc(db, `households/${householdId}/unknownIngredientQueue`, queueItem.id), { + status: 'resolved', + resolution: 'dismissed', + resolvedAt: new Date().toISOString(), + resolvedBy: role, + }); +} + +export async function promoteUnknownIngredientQueueItem(input: ResolveUnknownIngredientQueueItemInput): Promise { + const { db, householdId, queueItem, role } = input; + if (queueItem.status !== 'open') { + throw new Error(`Cannot promote queue item ${queueItem.id} because it is already resolved.`); + } + + const timestampIso = new Date().toISOString(); + const normalizedCategory = normalizePantryCategory(queueItem.category); + const visual = resolveIngredientVisual({ + name: queueItem.name, + category: normalizedCategory, + }); + const inventoryItemId = generateId(); + const inventoryItem: InventoryItem = { + id: inventoryItemId, + name: queueItem.name, + nameHi: getResolvedNameHi(undefined, visual.catalogMatch?.nativeName), + category: normalizedCategory, + status: queueItem.requestedStatus, + icon: visual.fallbackIcon, + requestedQuantity: queueItem.requestedQuantity, + lastUpdated: timestampIso, + updatedBy: role, + verificationNeeded: false, + anomalyReason: '', + }; + + const batch = writeBatch(db); + batch.set( + doc(db, `households/${householdId}/inventory`, inventoryItemId), + sanitizeFirestorePayload(inventoryItem), + ); + batch.update(doc(db, `households/${householdId}/unknownIngredientQueue`, queueItem.id), { + status: 'resolved', + resolution: 'promoted', + resolvedAt: timestampIso, + resolvedBy: role, + promotedInventoryItemId: inventoryItemId, + }); + await batch.commit(); +} + export async function clearAnomaly(db: Firestore, householdId: string, itemId: string): Promise { await updateDoc(doc(db, `households/${householdId}/inventory`, itemId), { verificationNeeded: false, diff --git a/src/services/seedService.ts b/src/services/seedService.ts index 3f55c1f..c091937 100644 --- a/src/services/seedService.ts +++ b/src/services/seedService.ts @@ -8,19 +8,51 @@ import { } from 'firebase/firestore'; import { InventoryItem, MealPlan } from '../types'; import { getLocalDateKey } from '../utils/date'; +import { resolveIngredientVisual } from '../utils/ingredientVisuals'; import { normalizePantryCategory } from '../utils/pantryCategory'; +function createSeedInventoryItem(item: Omit): InventoryItem { + const visual = resolveIngredientVisual({ + name: item.name, + nameHi: item.nameHi, + category: item.category, + }); + + return { + ...item, + icon: visual.fallbackIcon, + }; +} + const initialInventory: InventoryItem[] = [ - { id: '1', name: 'Turmeric (Haldi)', nameHi: 'हल्दी', category: normalizePantryCategory('Spices'), status: 'in-stock', icon: '🟡', defaultQuantity: '200g' }, - { id: '2', name: 'Red Chilli Powder', nameHi: 'लाल मिर्च', category: normalizePantryCategory('Spices'), status: 'in-stock', icon: '🌶️', defaultQuantity: '200g' }, - { id: '3', name: 'Garam Masala', nameHi: 'गरम मसाला', category: normalizePantryCategory('Spices'), status: 'in-stock', icon: '🧆', defaultQuantity: '100g' }, - { id: '4', name: 'Toor Dal', nameHi: 'तूर दाल', category: normalizePantryCategory('Pulses'), status: 'in-stock', icon: '🥣', defaultQuantity: '1kg' }, - { id: '5', name: 'Basmati Rice', nameHi: 'चावल', category: normalizePantryCategory('Staples'), status: 'in-stock', icon: '🍚', defaultQuantity: '5kg' }, - { id: '6', name: 'Atta (Wheat Flour)', nameHi: 'आटा', category: normalizePantryCategory('Staples'), status: 'low', icon: '🌾', defaultQuantity: '5kg' }, - { id: '7', name: 'Mustard Oil', nameHi: 'सरसों का तेल', category: normalizePantryCategory('Staples'), status: 'in-stock', icon: '🛢️', defaultQuantity: '1L' }, - { id: '8', name: 'Onions', nameHi: 'प्याज', category: normalizePantryCategory('Veggies'), status: 'in-stock', icon: '🧅', defaultQuantity: '2kg' }, - { id: '9', name: 'Tomatoes', nameHi: 'टमाटर', category: normalizePantryCategory('Veggies'), status: 'out', icon: '🍅', defaultQuantity: '1kg' }, - { id: '10', name: 'Milk', nameHi: 'दूध', category: normalizePantryCategory('Dairy'), status: 'in-stock', icon: '🥛', defaultQuantity: '1L' }, + createSeedInventoryItem({ id: '1', name: 'Turmeric (Haldi)', nameHi: 'हल्दी', category: normalizePantryCategory('Spices'), status: 'in-stock', defaultQuantity: '200g' }), + createSeedInventoryItem({ id: '2', name: 'Red Chilli Powder', nameHi: 'लाल मिर्च', category: normalizePantryCategory('Spices'), status: 'in-stock', defaultQuantity: '200g' }), + createSeedInventoryItem({ id: '3', name: 'Garam Masala', nameHi: 'गरम मसाला', category: normalizePantryCategory('Spices'), status: 'in-stock', defaultQuantity: '100g' }), + createSeedInventoryItem({ id: '4', name: 'Jeera (Cumin Seeds)', nameHi: 'जीरा', category: normalizePantryCategory('Spices'), status: 'in-stock', defaultQuantity: '200g' }), + createSeedInventoryItem({ id: '5', name: 'Dhania Powder', nameHi: 'धनिया पाउडर', category: normalizePantryCategory('Spices'), status: 'in-stock', defaultQuantity: '200g' }), + createSeedInventoryItem({ id: '6', name: 'Ajwain', nameHi: 'अजवाइन', category: normalizePantryCategory('Spices'), status: 'in-stock', defaultQuantity: '100g' }), + createSeedInventoryItem({ id: '7', name: 'Kasuri Methi', nameHi: 'कसूरी मेथी', category: normalizePantryCategory('Spices'), status: 'low', defaultQuantity: '50g' }), + createSeedInventoryItem({ id: '8', name: 'Toor Dal', nameHi: 'तूर दाल', category: normalizePantryCategory('Pulses'), status: 'in-stock', defaultQuantity: '1kg' }), + createSeedInventoryItem({ id: '9', name: 'Moong Dal', nameHi: 'मूंग दाल', category: normalizePantryCategory('Pulses'), status: 'in-stock', defaultQuantity: '1kg' }), + createSeedInventoryItem({ id: '10', name: 'Masoor Dal', nameHi: 'मसूर दाल', category: normalizePantryCategory('Pulses'), status: 'in-stock', defaultQuantity: '1kg' }), + createSeedInventoryItem({ id: '11', name: 'Chana Dal', nameHi: 'चना दाल', category: normalizePantryCategory('Pulses'), status: 'low', defaultQuantity: '1kg' }), + createSeedInventoryItem({ id: '12', name: 'Rajma', nameHi: 'राजमा', category: normalizePantryCategory('Pulses'), status: 'in-stock', defaultQuantity: '1kg' }), + createSeedInventoryItem({ id: '13', name: 'Basmati Rice', nameHi: 'चावल', category: normalizePantryCategory('Staples'), status: 'in-stock', defaultQuantity: '5kg' }), + createSeedInventoryItem({ id: '14', name: 'Atta (Wheat Flour)', nameHi: 'आटा', category: normalizePantryCategory('Staples'), status: 'low', defaultQuantity: '10kg' }), + createSeedInventoryItem({ id: '15', name: 'Besan', nameHi: 'बेसन', category: normalizePantryCategory('Staples'), status: 'in-stock', defaultQuantity: '1kg' }), + createSeedInventoryItem({ id: '16', name: 'Suji (Semolina)', nameHi: 'सूजी', category: normalizePantryCategory('Staples'), status: 'in-stock', defaultQuantity: '500g' }), + createSeedInventoryItem({ id: '17', name: 'Mustard Oil', nameHi: 'सरसों का तेल', category: normalizePantryCategory('Staples'), status: 'in-stock', defaultQuantity: '1L' }), + createSeedInventoryItem({ id: '18', name: 'Ghee', nameHi: 'घी', category: normalizePantryCategory('Dairy'), status: 'in-stock', defaultQuantity: '500g' }), + createSeedInventoryItem({ id: '19', name: 'Onions', nameHi: 'प्याज', category: normalizePantryCategory('Veggies'), status: 'in-stock', defaultQuantity: '2kg' }), + createSeedInventoryItem({ id: '20', name: 'Tomatoes', nameHi: 'टमाटर', category: normalizePantryCategory('Veggies'), status: 'out', defaultQuantity: '1kg' }), + createSeedInventoryItem({ id: '21', name: 'Potatoes', nameHi: 'आलू', category: normalizePantryCategory('Veggies'), status: 'in-stock', defaultQuantity: '3kg' }), + createSeedInventoryItem({ id: '22', name: 'Ginger', nameHi: 'अदरक', category: normalizePantryCategory('Veggies'), status: 'in-stock', defaultQuantity: '250g' }), + createSeedInventoryItem({ id: '23', name: 'Garlic', nameHi: 'लहसुन', category: normalizePantryCategory('Veggies'), status: 'in-stock', defaultQuantity: '250g' }), + createSeedInventoryItem({ id: '24', name: 'Green Chillies', nameHi: 'हरी मिर्च', category: normalizePantryCategory('Veggies'), status: 'low', defaultQuantity: '100g' }), + createSeedInventoryItem({ id: '25', name: 'Coriander Leaves', nameHi: 'हरा धनिया', category: normalizePantryCategory('Veggies'), status: 'low', defaultQuantity: '2 bunches' }), + createSeedInventoryItem({ id: '26', name: 'Milk', nameHi: 'दूध', category: normalizePantryCategory('Dairy'), status: 'in-stock', defaultQuantity: '1L' }), + createSeedInventoryItem({ id: '27', name: 'Curd (Dahi)', nameHi: 'दही', category: normalizePantryCategory('Dairy'), status: 'in-stock', defaultQuantity: '500g' }), + createSeedInventoryItem({ id: '28', name: 'Paneer', nameHi: 'पनीर', category: normalizePantryCategory('Dairy'), status: 'in-stock', defaultQuantity: '400g' }), ]; function getInitialMeals(): Record { diff --git a/src/types.ts b/src/types.ts index 4c59ce1..577a911 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,11 +36,29 @@ export interface PantryLog { role: Role; } +export type UnknownIngredientQueueStatus = 'open' | 'resolved'; + +export interface UnknownIngredientQueueItem { + id: string; + name: string; + category: string; + status: UnknownIngredientQueueStatus; + requestedStatus: InventoryStatus; + requestedQuantity?: string; + createdAt: string; + createdBy: Role; + resolvedAt?: string; + resolvedBy?: Role; + resolution?: 'promoted' | 'dismissed'; + promotedInventoryItemId?: string; +} + export interface AppState { role: Role; inventory: InventoryItem[]; meals: Record; logs: PantryLog[]; + unknownIngredientQueue: UnknownIngredientQueueItem[]; } export interface HouseholdPreferences { diff --git a/src/utils/ingredientVisuals.ts b/src/utils/ingredientVisuals.ts new file mode 100644 index 0000000..891fd0c --- /dev/null +++ b/src/utils/ingredientVisuals.ts @@ -0,0 +1,705 @@ +import { InventoryItem } from '../types'; +import { PantryCategoryKey, normalizePantryCategory } from './pantryCategory'; + +export type IngredientVisualSource = 'catalog-match' | 'existing-icon' | 'category-fallback'; + +export interface IngredientCatalogMetadata { + key: IngredientVisualKey; + canonicalName: string; + transliteration: string; + nativeName: string; + matchedKeyword: string; +} + +export interface IngredientVisual { + imageUrl: string | null; + fallbackIcon: string; + altText: string; + source: IngredientVisualSource; + catalogMatch?: IngredientCatalogMetadata; +} + +export interface IngredientVisualInput { + name: string; + nameHi?: string; + category: string; + icon?: string; +} + +interface IngredientCatalogEntry { + key: TKey; + fallbackIcon: string; + altText: string; + imageObjectKey: string; + canonicalName: string; + transliteration: string; + nativeName: string; + keywords: readonly string[]; +} + +interface IngredientCatalogEntryDefinition { + key: TKey; + fallbackIcon: string; + canonicalName: string; + transliteration: string; + nativeName: string; + imageObjectKey: string; + keywords: readonly string[]; +} + +interface IngredientCatalogMatch { + entry: IngredientCatalogEntry; + normalizedKeyword: string; + score: number; + entryIndex: number; +} + +const CATEGORY_FALLBACK_ICONS: Record = { + spices: '🫙', + pulses: '🥣', + staples: '🌾', + veggies: '🥕', + dairy: '🥛', + other: '📦', +}; + +function buildCatalogImageUrl(baseUrl: string, objectKey: string): string { + return `${baseUrl}/${encodeURIComponent(objectKey)}`; +} + +function normalizeCatalogImageBaseUrl(rawBaseUrl: string): string { + const trimmedValue = rawBaseUrl.trim(); + if (trimmedValue.length === 0) { + throw new Error('VITE_INGREDIENT_IMAGE_BASE_URL is configured but empty.'); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(trimmedValue); + } catch (error) { + throw new Error(`VITE_INGREDIENT_IMAGE_BASE_URL is invalid: ${trimmedValue}`, { + cause: error instanceof Error ? error : undefined, + }); + } + + if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { + throw new Error(`VITE_INGREDIENT_IMAGE_BASE_URL must use http or https: ${trimmedValue}`); + } + + return trimmedValue.replace(/\/+$/u, ''); +} + +function getCatalogImageBaseUrlFromEnv(): string | undefined { + const viteEnvCandidate = (import.meta as ImportMeta & { env?: Record }).env; + const processEnvCandidate = typeof process !== 'undefined' ? process.env : undefined; + return viteEnvCandidate?.VITE_INGREDIENT_IMAGE_BASE_URL ?? processEnvCandidate?.VITE_INGREDIENT_IMAGE_BASE_URL; +} + +let cachedCatalogImageBaseUrlConfig: string | undefined; +let cachedCatalogImageBaseUrlValue: string | null | undefined; + +function getCatalogImageBaseUrl(): string | null { + const configuredBaseUrl = getCatalogImageBaseUrlFromEnv(); + if (cachedCatalogImageBaseUrlValue !== undefined && cachedCatalogImageBaseUrlConfig === configuredBaseUrl) { + return cachedCatalogImageBaseUrlValue; + } + + if (configuredBaseUrl === undefined) { + cachedCatalogImageBaseUrlConfig = configuredBaseUrl; + cachedCatalogImageBaseUrlValue = null; + return cachedCatalogImageBaseUrlValue; + } + + try { + cachedCatalogImageBaseUrlConfig = configuredBaseUrl; + cachedCatalogImageBaseUrlValue = normalizeCatalogImageBaseUrl(configuredBaseUrl); + return cachedCatalogImageBaseUrlValue; + } catch (error) { + console.warn( + 'Ingredient catalog images are disabled because VITE_INGREDIENT_IMAGE_BASE_URL is invalid.', + { + configuredBaseUrl, + error: error instanceof Error ? error.message : String(error), + }, + ); + cachedCatalogImageBaseUrlConfig = configuredBaseUrl; + cachedCatalogImageBaseUrlValue = null; + return cachedCatalogImageBaseUrlValue; + } +} + +function createIngredientCatalogEntry( + definition: IngredientCatalogEntryDefinition, +): IngredientCatalogEntry { + return { + key: definition.key, + fallbackIcon: definition.fallbackIcon, + altText: `Photo of ${definition.canonicalName.toLowerCase()} (${definition.transliteration})`, + imageObjectKey: definition.imageObjectKey, + canonicalName: definition.canonicalName, + transliteration: definition.transliteration, + nativeName: definition.nativeName, + keywords: [ + definition.canonicalName, + definition.transliteration, + definition.nativeName, + ...definition.keywords, + ], + }; +} + +const INGREDIENT_CATALOG = [ + createIngredientCatalogEntry({ + key: 'turmeric', + fallbackIcon: '🟡', + canonicalName: 'Turmeric', + transliteration: 'Haldi', + nativeName: 'हल्दी', + imageObjectKey: 'turmeric.webp', + keywords: ['turmeric powder'], + }), + createIngredientCatalogEntry({ + key: 'red-chilli', + fallbackIcon: '🌶️', + canonicalName: 'Red Chilli Powder', + transliteration: 'Lal Mirch', + nativeName: 'लाल मिर्च', + imageObjectKey: 'red-chilli.webp', + keywords: ['red chili powder', 'red chilli', 'red chili', 'kashmiri mirch', 'lal mirch powder'], + }), + createIngredientCatalogEntry({ + key: 'coriander-powder', + fallbackIcon: '🟤', + canonicalName: 'Coriander Powder', + transliteration: 'Dhania Powder', + nativeName: 'धनिया पाउडर', + imageObjectKey: 'coriander-powder.webp', + keywords: ['coriander', 'dhania', 'ground coriander'], + }), + createIngredientCatalogEntry({ + key: 'garam-masala', + fallbackIcon: '🧆', + canonicalName: 'Garam Masala', + transliteration: 'Garam Masala', + nativeName: 'गरम मसाला', + imageObjectKey: 'garam-masala.webp', + keywords: ['punjabi garam masala'], + }), + createIngredientCatalogEntry({ + key: 'cumin', + fallbackIcon: '🟤', + canonicalName: 'Cumin Seeds', + transliteration: 'Jeera', + nativeName: 'जीरा', + imageObjectKey: 'cumin.webp', + keywords: ['cumin', 'cumin seed', 'cumin seeds', 'jeera seeds'], + }), + createIngredientCatalogEntry({ + key: 'mustard-seeds', + fallbackIcon: '⚫', + canonicalName: 'Mustard Seeds', + transliteration: 'Rai', + nativeName: 'राई', + imageObjectKey: 'mustard-seeds.webp', + keywords: ['mustard seed', 'sarson dana', 'rai dana'], + }), + createIngredientCatalogEntry({ + key: 'ajwain', + fallbackIcon: '🟫', + canonicalName: 'Carom Seeds', + transliteration: 'Ajwain', + nativeName: 'अजवाइन', + imageObjectKey: 'ajwain.webp', + keywords: ['ajowan', 'carom seed', 'carom seeds'], + }), + createIngredientCatalogEntry({ + key: 'hing', + fallbackIcon: '🧂', + canonicalName: 'Asafoetida', + transliteration: 'Hing', + nativeName: 'हींग', + imageObjectKey: 'hing.webp', + keywords: ['asafoetida powder'], + }), + createIngredientCatalogEntry({ + key: 'kasuri-methi', + fallbackIcon: '🌿', + canonicalName: 'Dried Fenugreek Leaves', + transliteration: 'Kasuri Methi', + nativeName: 'कसूरी मेथी', + imageObjectKey: 'kasuri-methi.webp', + keywords: ['fenugreek leaves', 'dried fenugreek', 'methi leaves'], + }), + createIngredientCatalogEntry({ + key: 'black-pepper', + fallbackIcon: '⚫', + canonicalName: 'Black Pepper', + transliteration: 'Kali Mirch', + nativeName: 'काली मिर्च', + imageObjectKey: 'black-pepper.webp', + keywords: ['peppercorn', 'peppercorns'], + }), + createIngredientCatalogEntry({ + key: 'cinnamon', + fallbackIcon: '🪵', + canonicalName: 'Cinnamon', + transliteration: 'Dalchini', + nativeName: 'दालचीनी', + imageObjectKey: 'cinnamon.webp', + keywords: ['cinnamon stick', 'cinnamon sticks'], + }), + createIngredientCatalogEntry({ + key: 'cardamom', + fallbackIcon: '🫛', + canonicalName: 'Green Cardamom', + transliteration: 'Elaichi', + nativeName: 'इलायची', + imageObjectKey: 'cardamom.webp', + keywords: ['cardamom', 'cardamom pods', 'green elaichi', 'elaichi dana'], + }), + createIngredientCatalogEntry({ + key: 'clove', + fallbackIcon: '📍', + canonicalName: 'Cloves', + transliteration: 'Laung', + nativeName: 'लौंग', + imageObjectKey: 'clove.webp', + keywords: ['clove', 'whole cloves'], + }), + createIngredientCatalogEntry({ + key: 'bay-leaf', + fallbackIcon: '🍃', + canonicalName: 'Bay Leaf', + transliteration: 'Tej Patta', + nativeName: 'तेज पत्ता', + imageObjectKey: 'bay-leaf.webp', + keywords: ['bay leaves', 'tej patta'], + }), + createIngredientCatalogEntry({ + key: 'mustard-oil', + fallbackIcon: '🛢️', + canonicalName: 'Mustard Oil', + transliteration: 'Sarson Ka Tel', + nativeName: 'सरसों का तेल', + imageObjectKey: 'mustard-oil.webp', + keywords: ['sarson oil', 'sarso oil'], + }), + createIngredientCatalogEntry({ + key: 'sunflower-oil', + fallbackIcon: '🛢️', + canonicalName: 'Sunflower Oil', + transliteration: 'Sunflower Tel', + nativeName: 'सनफ्लावर तेल', + imageObjectKey: 'sunflower-oil.webp', + keywords: ['refined oil', 'refined tel', 'cooking oil'], + }), + createIngredientCatalogEntry({ + key: 'ghee', + fallbackIcon: '🫙', + canonicalName: 'Ghee', + transliteration: 'Ghee', + nativeName: 'घी', + imageObjectKey: 'ghee.webp', + keywords: ['desi ghee', 'clarified butter'], + }), + createIngredientCatalogEntry({ + key: 'onion', + fallbackIcon: '🧅', + canonicalName: 'Onion', + transliteration: 'Pyaz', + nativeName: 'प्याज', + imageObjectKey: 'onion.webp', + keywords: ['onions', 'pyaaz'], + }), + createIngredientCatalogEntry({ + key: 'tomato', + fallbackIcon: '🍅', + canonicalName: 'Tomato', + transliteration: 'Tamatar', + nativeName: 'टमाटर', + imageObjectKey: 'tomato.webp', + keywords: ['tomatoes'], + }), + createIngredientCatalogEntry({ + key: 'potato', + fallbackIcon: '🥔', + canonicalName: 'Potato', + transliteration: 'Aloo', + nativeName: 'आलू', + imageObjectKey: 'potato.webp', + keywords: ['potatoes'], + }), + createIngredientCatalogEntry({ + key: 'ginger', + fallbackIcon: '🫚', + canonicalName: 'Ginger', + transliteration: 'Adrak', + nativeName: 'अदरक', + imageObjectKey: 'ginger.webp', + keywords: ['fresh ginger'], + }), + createIngredientCatalogEntry({ + key: 'garlic', + fallbackIcon: '🧄', + canonicalName: 'Garlic', + transliteration: 'Lehsun', + nativeName: 'लहसुन', + imageObjectKey: 'garlic.webp', + keywords: ['lahsun', 'garlic cloves'], + }), + createIngredientCatalogEntry({ + key: 'green-chilli', + fallbackIcon: '🌶️', + canonicalName: 'Green Chilli', + transliteration: 'Hari Mirch', + nativeName: 'हरी मिर्च', + imageObjectKey: 'green-chilli.webp', + keywords: ['green chili', 'green chillies', 'green chiles', 'hari mirch'], + }), + createIngredientCatalogEntry({ + key: 'coriander-leaves', + fallbackIcon: '🌿', + canonicalName: 'Coriander Leaves', + transliteration: 'Hara Dhania', + nativeName: 'हरा धनिया', + imageObjectKey: 'coriander-leaves.webp', + keywords: ['coriander leaf', 'coriander leaves', 'cilantro', 'dhania patta', 'hara dhaniya'], + }), + createIngredientCatalogEntry({ + key: 'mint', + fallbackIcon: '🌿', + canonicalName: 'Mint Leaves', + transliteration: 'Pudina', + nativeName: 'पुदीना', + imageObjectKey: 'mint.webp', + keywords: ['mint', 'mint leaf', 'mint leaves'], + }), + createIngredientCatalogEntry({ + key: 'lemon', + fallbackIcon: '🍋', + canonicalName: 'Lemon', + transliteration: 'Nimbu', + nativeName: 'नींबू', + imageObjectKey: 'lemon.webp', + keywords: ['lemons', 'nimbu'], + }), + createIngredientCatalogEntry({ + key: 'rice', + fallbackIcon: '🍚', + canonicalName: 'Basmati Rice', + transliteration: 'Chawal', + nativeName: 'चावल', + imageObjectKey: 'rice.webp', + keywords: ['rice', 'rice grains', 'basmati', 'basmati chawal'], + }), + createIngredientCatalogEntry({ + key: 'atta', + fallbackIcon: '🌾', + canonicalName: 'Whole Wheat Flour', + transliteration: 'Atta', + nativeName: 'आटा', + imageObjectKey: 'atta.webp', + keywords: ['atta flour', 'wheat flour', 'whole wheat atta', 'gehun ka atta', 'गेहूं का आटा'], + }), + createIngredientCatalogEntry({ + key: 'besan', + fallbackIcon: '🟨', + canonicalName: 'Gram Flour', + transliteration: 'Besan', + nativeName: 'बेसन', + imageObjectKey: 'besan.webp', + keywords: ['chickpea flour', 'gram flour', 'besan flour'], + }), + createIngredientCatalogEntry({ + key: 'suji', + fallbackIcon: '🥣', + canonicalName: 'Semolina', + transliteration: 'Suji', + nativeName: 'सूजी', + imageObjectKey: 'suji.webp', + keywords: ['sooji', 'rava', 'rawa', 'semolina', 'upma rava'], + }), + createIngredientCatalogEntry({ + key: 'maida', + fallbackIcon: '⚪', + canonicalName: 'Refined Flour', + transliteration: 'Maida', + nativeName: 'मैदा', + imageObjectKey: 'maida.webp', + keywords: ['all purpose flour', 'all-purpose flour', 'plain flour'], + }), + createIngredientCatalogEntry({ + key: 'poha', + fallbackIcon: '🍚', + canonicalName: 'Flattened Rice', + transliteration: 'Poha', + nativeName: 'पोहा', + imageObjectKey: 'poha.webp', + keywords: ['beaten rice', 'flattened rice', 'poha rice'], + }), + createIngredientCatalogEntry({ + key: 'toor-dal', + fallbackIcon: '🥣', + canonicalName: 'Toor Dal', + transliteration: 'Arhar Dal', + nativeName: 'तूर दाल', + imageObjectKey: 'toor-dal.webp', + keywords: ['tur dal', 'tuar dal', 'arhar', 'arhar dal', 'pigeon pea', 'pigeon peas'], + }), + createIngredientCatalogEntry({ + key: 'moong-dal', + fallbackIcon: '🥣', + canonicalName: 'Moong Dal', + transliteration: 'Moong Dal', + nativeName: 'मूंग दाल', + imageObjectKey: 'moong-dal.webp', + keywords: ['mung dal', 'moong', 'yellow moong dal', 'split moong dal'], + }), + createIngredientCatalogEntry({ + key: 'masoor-dal', + fallbackIcon: '🥣', + canonicalName: 'Masoor Dal', + transliteration: 'Masoor Dal', + nativeName: 'मसूर दाल', + imageObjectKey: 'masoor-dal.webp', + keywords: ['red lentil', 'red lentils', 'masoor'], + }), + createIngredientCatalogEntry({ + key: 'chana-dal', + fallbackIcon: '🥣', + canonicalName: 'Chana Dal', + transliteration: 'Chana Dal', + nativeName: 'चना दाल', + imageObjectKey: 'chana-dal.webp', + keywords: ['split chickpea', 'split chickpeas', 'split bengal gram', 'bengal gram'], + }), + createIngredientCatalogEntry({ + key: 'urad-dal', + fallbackIcon: '🥣', + canonicalName: 'Urad Dal', + transliteration: 'Urad Dal', + nativeName: 'उड़द दाल', + imageObjectKey: 'urad-dal.webp', + keywords: ['udad dal', 'urad', 'black gram'], + }), + createIngredientCatalogEntry({ + key: 'rajma', + fallbackIcon: '🫘', + canonicalName: 'Kidney Beans', + transliteration: 'Rajma', + nativeName: 'राजमा', + imageObjectKey: 'rajma.webp', + keywords: ['rajma beans', 'kidney bean', 'kidney beans'], + }), + createIngredientCatalogEntry({ + key: 'chole', + fallbackIcon: '🫘', + canonicalName: 'Chickpeas', + transliteration: 'Chole', + nativeName: 'छोले', + imageObjectKey: 'chole.webp', + keywords: ['kabuli chana', 'chana', 'white chana', 'white chickpeas', 'chickpea', 'chickpeas'], + }), + createIngredientCatalogEntry({ + key: 'milk', + fallbackIcon: '🥛', + canonicalName: 'Milk', + transliteration: 'Doodh', + nativeName: 'दूध', + imageObjectKey: 'milk.webp', + keywords: ['full cream milk'], + }), + createIngredientCatalogEntry({ + key: 'curd', + fallbackIcon: '🥣', + canonicalName: 'Curd', + transliteration: 'Dahi', + nativeName: 'दही', + imageObjectKey: 'curd.webp', + keywords: ['yogurt', 'yoghurt', 'plain curd'], + }), + createIngredientCatalogEntry({ + key: 'paneer', + fallbackIcon: '🧈', + canonicalName: 'Paneer', + transliteration: 'Paneer', + nativeName: 'पनीर', + imageObjectKey: 'paneer.webp', + keywords: ['cottage cheese', 'paneer cubes'], + }), + createIngredientCatalogEntry({ + key: 'salt', + fallbackIcon: '🧂', + canonicalName: 'Salt', + transliteration: 'Namak', + nativeName: 'नमक', + imageObjectKey: 'salt.webp', + keywords: ['table salt', 'iodized salt'], + }), + createIngredientCatalogEntry({ + key: 'black-salt', + fallbackIcon: '🧂', + canonicalName: 'Black Salt', + transliteration: 'Kala Namak', + nativeName: 'काला नमक', + imageObjectKey: 'black-salt.webp', + keywords: ['kala namak', 'black salt powder'], + }), + createIngredientCatalogEntry({ + key: 'sugar', + fallbackIcon: '🍚', + canonicalName: 'Sugar', + transliteration: 'Cheeni', + nativeName: 'चीनी', + imageObjectKey: 'sugar.webp', + keywords: ['granulated sugar'], + }), +] as const; + +function assertIngredientCatalogImageCoverage(catalog: readonly IngredientCatalogEntry[]): void { + const seenImageObjectKeys = new Set(); + for (const entry of catalog) { + const normalizedImageObjectKey = entry.imageObjectKey.trim(); + if (normalizedImageObjectKey.length === 0) { + throw new Error(`Ingredient catalog entry "${entry.key}" is missing imageObjectKey.`); + } + + if (seenImageObjectKeys.has(normalizedImageObjectKey)) { + throw new Error(`Ingredient catalog imageObjectKey is duplicated: ${normalizedImageObjectKey}`); + } + + seenImageObjectKeys.add(normalizedImageObjectKey); + } +} + +assertIngredientCatalogImageCoverage(INGREDIENT_CATALOG); + +export type IngredientVisualKey = (typeof INGREDIENT_CATALOG)[number]['key']; + +function normalizeIngredientText(value: string): string { + return value + .trim() + .toLowerCase() + .normalize('NFKC') + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function buildIngredientSearchText(input: IngredientVisualInput): string { + return normalizeIngredientText([input.name, input.nameHi].filter(Boolean).join(' ')); +} + +function matchesKeyword(searchText: string, keyword: string): boolean { + const paddedSearchText = ` ${searchText} `; + const paddedKeyword = ` ${keyword} `; + return paddedSearchText.includes(paddedKeyword); +} + +function compareIngredientCatalogMatches(candidate: IngredientCatalogMatch, currentBest: IngredientCatalogMatch): number { + if (candidate.score !== currentBest.score) { + return candidate.score - currentBest.score; + } + + if (candidate.entryIndex !== currentBest.entryIndex) { + return currentBest.entryIndex - candidate.entryIndex; + } + + return currentBest.normalizedKeyword.localeCompare(candidate.normalizedKeyword); +} + +function findIngredientCatalogMatch(searchText: string): IngredientCatalogMatch | null { + let bestMatch: IngredientCatalogMatch | null = null; + + for (const [entryIndex, entry] of INGREDIENT_CATALOG.entries()) { + for (const keywordValue of entry.keywords) { + const normalizedKeyword = normalizeIngredientText(keywordValue); + if (normalizedKeyword.length === 0 || !matchesKeyword(searchText, normalizedKeyword)) { + continue; + } + + const candidate: IngredientCatalogMatch = { + entry, + normalizedKeyword, + score: (searchText === normalizedKeyword ? 1000 : 0) + normalizedKeyword.length, + entryIndex, + }; + + if (bestMatch === null || compareIngredientCatalogMatches(candidate, bestMatch) > 0) { + bestMatch = candidate; + } + } + } + + return bestMatch; +} + +function hasRenderableIcon(icon: string | undefined): icon is string { + return typeof icon === 'string' && icon.trim().length > 0; +} + +function getFallbackAltText(input: IngredientVisualInput): string { + return `Ingredient icon for ${input.name}`; +} + +function createIngredientCatalogMetadata(match: IngredientCatalogMatch): IngredientCatalogMetadata { + return { + key: match.entry.key as IngredientVisualKey, + canonicalName: match.entry.canonicalName, + transliteration: match.entry.transliteration, + nativeName: match.entry.nativeName, + matchedKeyword: match.normalizedKeyword, + }; +} + +export function getIngredientNativeContextLabel(input: Pick, visual: IngredientVisual): string | null { + const trimmedNameHi = typeof input.nameHi === 'string' ? input.nameHi.trim() : ''; + if (visual.catalogMatch !== undefined) { + const resolvedNativeName = trimmedNameHi.length > 0 ? trimmedNameHi : visual.catalogMatch.nativeName; + return `${visual.catalogMatch.transliteration} / ${resolvedNativeName}`; + } + + return trimmedNameHi.length > 0 ? trimmedNameHi : null; +} + +export function resolveIngredientVisual(input: IngredientVisualInput): IngredientVisual { + const searchText = buildIngredientSearchText(input); + const catalogMatch = findIngredientCatalogMatch(searchText); + if (catalogMatch !== null) { + const imageBaseUrl = getCatalogImageBaseUrl(); + const imageUrl = imageBaseUrl === null ? null : buildCatalogImageUrl(imageBaseUrl, catalogMatch.entry.imageObjectKey); + return { + imageUrl, + fallbackIcon: catalogMatch.entry.fallbackIcon, + altText: catalogMatch.entry.altText, + source: 'catalog-match', + catalogMatch: createIngredientCatalogMetadata(catalogMatch), + }; + } + + if (hasRenderableIcon(input.icon)) { + return { + imageUrl: null, + fallbackIcon: input.icon.trim(), + altText: getFallbackAltText(input), + source: 'existing-icon', + }; + } + + const category = normalizePantryCategory(input.category); + return { + imageUrl: null, + fallbackIcon: CATEGORY_FALLBACK_ICONS[category], + altText: getFallbackAltText(input), + source: 'category-fallback', + }; +} + +export function resolveInventoryItemVisual(item: InventoryItem): IngredientVisual { + return resolveIngredientVisual({ + name: item.name, + nameHi: item.nameHi, + category: item.category, + icon: item.icon, + }); +} diff --git a/src/utils/unknownQueue.ts b/src/utils/unknownQueue.ts new file mode 100644 index 0000000..a10d53d --- /dev/null +++ b/src/utils/unknownQueue.ts @@ -0,0 +1,123 @@ +import { UnknownIngredientQueueItem } from '../types'; + +export interface FirestoreListenerErrorInfo { + code: string | null; + message: string | null; + name: string | null; +} + +export interface UnknownQueueTargetFingerprintInput { + databaseId: string; + householdId: string; + projectId: string; +} + +export interface HouseholdMembershipProbeInput { + householdCookEmail: string | null; + householdExists: boolean; + householdOwnerId: string | null; + userEmail: string | null; + userUid: string; +} + +export type HouseholdMembershipProbeResult = 'owner' | 'cook' | 'non-member' | 'household-missing'; + +function toRecord(value: unknown): Record | null { + if (typeof value !== 'object' || value === null) { + return null; + } + return value as Record; +} + +function toOptionalString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function toTimestampMs(value: string): number | null { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +export function toFirestoreListenerErrorInfo(error: unknown): FirestoreListenerErrorInfo { + const record = toRecord(error); + return { + code: toOptionalString(record?.code), + message: toOptionalString(record?.message), + name: toOptionalString(record?.name), + }; +} + +export function isFirestoreFailedPreconditionError(error: FirestoreListenerErrorInfo): boolean { + return error.code === 'failed-precondition'; +} + +export function isFirestorePermissionDeniedError(error: FirestoreListenerErrorInfo): boolean { + return error.code === 'permission-denied'; +} + +function normalizeEmail(value: string | null): string { + return value === null ? '' : value.trim().toLowerCase(); +} + +export function classifyHouseholdMembershipProbe(input: HouseholdMembershipProbeInput): HouseholdMembershipProbeResult { + if (!input.householdExists) { + return 'household-missing'; + } + + if (input.householdOwnerId === input.userUid) { + return 'owner'; + } + + const normalizedCookEmail = normalizeEmail(input.householdCookEmail); + const normalizedUserEmail = normalizeEmail(input.userEmail); + if (normalizedCookEmail.length > 0 && normalizedCookEmail === normalizedUserEmail) { + return 'cook'; + } + + return 'non-member'; +} + +export function buildUnknownQueueTargetFingerprint(input: UnknownQueueTargetFingerprintInput): string { + return `${input.projectId}/${input.databaseId}/households/${input.householdId}/unknownIngredientQueue`; +} + +export function getUnknownQueueLoadErrorMessage( + error: FirestoreListenerErrorInfo, + membershipProbeResult: HouseholdMembershipProbeResult | null, +): string { + if (isFirestorePermissionDeniedError(error)) { + if (membershipProbeResult === 'non-member' || membershipProbeResult === 'household-missing') { + return 'Unknown ingredient queue access denied. Household membership mismatch suspected.'; + } + return 'Unknown ingredient queue access denied. Firestore target mismatch suspected. Verify project/database rules deployment.'; + } + + if (isFirestoreFailedPreconditionError(error)) { + return 'Unknown ingredient queue index is missing. Showing fallback order while index is provisioned.'; + } + + return 'Failed to load unknown ingredient queue.'; +} + +export function sortUnknownIngredientQueueItemsByCreatedAt(items: UnknownIngredientQueueItem[]): UnknownIngredientQueueItem[] { + return [...items].sort((leftItem, rightItem) => { + const rightTime = toTimestampMs(rightItem.createdAt); + const leftTime = toTimestampMs(leftItem.createdAt); + + if (rightTime !== null && leftTime !== null) { + if (rightTime !== leftTime) { + return rightTime - leftTime; + } + } else if (rightTime !== null && leftTime === null) { + return 1; + } else if (rightTime === null && leftTime !== null) { + return -1; + } + + if (rightItem.createdAt !== leftItem.createdAt) { + return rightItem.createdAt.localeCompare(leftItem.createdAt); + } + + return rightItem.id.localeCompare(leftItem.id); + }); +} diff --git a/test/e2e/artifacts/cook-ai-journey.png b/test/e2e/artifacts/cook-ai-journey.png new file mode 100644 index 0000000..9dcb6e5 Binary files /dev/null and b/test/e2e/artifacts/cook-ai-journey.png differ diff --git a/test/e2e/artifacts/note-save-path.png b/test/e2e/artifacts/note-save-path.png new file mode 100644 index 0000000..9d2911b Binary files /dev/null and b/test/e2e/artifacts/note-save-path.png differ diff --git a/test/e2e/artifacts/owner-core-journey.png b/test/e2e/artifacts/owner-core-journey.png new file mode 100644 index 0000000..a98f900 Binary files /dev/null and b/test/e2e/artifacts/owner-core-journey.png differ diff --git a/test/e2e/artifacts/remove-from-grocery-path.png b/test/e2e/artifacts/remove-from-grocery-path.png new file mode 100644 index 0000000..322af2f Binary files /dev/null and b/test/e2e/artifacts/remove-from-grocery-path.png differ diff --git a/test/e2e/artifacts/summary.json b/test/e2e/artifacts/summary.json index 46a7b02..3228182 100644 --- a/test/e2e/artifacts/summary.json +++ b/test/e2e/artifacts/summary.json @@ -1,13 +1,13 @@ { - "startedAt": "2026-03-23T16:08:17.174Z", - "finishedAt": "2026-03-23T16:08:21.690Z", + "startedAt": "2026-03-25T14:24:01.381Z", + "finishedAt": "2026-03-25T14:24:11.431Z", "baseUrl": "http://127.0.0.1:3000", "browserExecutablePath": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "overallPass": true, + "overallPass": false, "results": [ { "name": "owner-core-journey", - "pass": true, + "pass": false, "repro": [ "Open /?e2e-role=owner.", "Click \"Sign in with Google\".", @@ -15,18 +15,30 @@ "Open Grocery List and mark Tomatoes as bought.", "Open Pantry & Logs, add Jeera, then mark it Running Low.", "Open Activity Logs and verify the new entry is shown." - ] + ], + "error": { + "name": "Error", + "message": "Unable to click button \"Mark Bought\" for row containing \"Tomatoes\".", + "stack": "Error: Unable to click button \"Mark Bought\" for row containing \"Tomatoes\".\n at clickTableRowButton (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:330:11)\n at async Object.runOwnerCoreFlow [as run] (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:483:3)\n at async runScenario (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:740:12)\n at async main (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:899:24)" + }, + "screenshotPath": "/Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/artifacts/owner-core-journey.png" }, { "name": "cook-ai-journey", - "pass": true, + "pass": false, "repro": [ "Open /?e2e-role=cook.", "Click \"Sign in with Google\".", - "Switch to English.", + "Use default cook language profile.", "Use Smart Assistant with the standard pantry update prompt.", - "Search Tomatoes, mark it Full, add a note for Atta, and verify the note is rendered." - ] + "Search tomatoes, mark it Full, add a note for atta, and verify the note is rendered." + ], + "error": { + "name": "Error", + "message": "Unable to set cook status Full for Tomatoes.", + "stack": "Error: Unable to set cook status Full for Tomatoes.\n at setCookStatus (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:368:11)\n at async Object.runCookCoreFlow [as run] (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:525:3)\n at async runScenario (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:740:12)\n at async main (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:899:24)" + }, + "screenshotPath": "/Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/artifacts/cook-ai-journey.png" }, { "name": "ai-malformed-response", @@ -34,7 +46,7 @@ "repro": [ "Open /?e2e-role=cook.", "Click \"Sign in with Google\".", - "Switch to English.", + "Use default cook language profile.", "Submit the malformed AI marker prompt.", "Verify the safe AI error message is rendered and the page stays usable." ] @@ -45,34 +57,73 @@ "repro": [ "Open /?e2e-role=cook.", "Click \"Sign in with Google\".", - "Switch to English.", + "Use default cook language profile.", "Submit the unmatched item marker prompt.", "Verify the successful update path runs and Milk moves onto the list." ] }, { "name": "note-save-path", - "pass": true, + "pass": false, "repro": [ "Open /?e2e-role=cook.", "Click \"Sign in with Google\".", - "Switch to English.", + "Use default cook language profile.", "Search for Atta.", "Add a note and save it.", "Verify the note renders as saved quantity text." - ] + ], + "error": { + "name": "Error", + "message": "Unable to open note editor for Atta.", + "stack": "Error: Unable to open note editor for Atta.\n at setCookNote (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:402:11)\n at async Object.runNoteSavePathCheck [as run] (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:617:3)\n at async runScenario (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:740:12)\n at async main (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:899:24)" + }, + "screenshotPath": "/Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/artifacts/note-save-path.png" }, { "name": "remove-from-grocery-path", - "pass": true, + "pass": false, "repro": [ "Open /?e2e-role=owner.", "Click \"Sign in with Google\".", "Open Grocery List.", "Mark Tomatoes as bought.", "Verify Tomatoes is removed from the grocery list." + ], + "error": { + "name": "Error", + "message": "Unable to click button \"Mark Bought\" for row containing \"Tomatoes\".", + "stack": "Error: Unable to click button \"Mark Bought\" for row containing \"Tomatoes\".\n at clickTableRowButton (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:330:11)\n at async Object.runRemoveFromGroceryPathCheck [as run] (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:642:3)\n at async runScenario (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:740:12)\n at async main (file:///Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/run.mjs:899:24)" + }, + "screenshotPath": "/Users/vijaysehgal/Downloads/02-Portfolio/Rasoi-Planner-Project/test/e2e/artifacts/remove-from-grocery-path.png" + }, + { + "name": "owner-tab-keyboard-navigation", + "pass": true, + "repro": [ + "Open /?e2e-role=owner and sign in.", + "Focus owner tabs and navigate with ArrowRight, End, and Home.", + "Verify selected tab and panel mapping after each keypress." + ] + }, + { + "name": "owner-tab-keyboard-navigation-mobile", + "pass": true, + "repro": [ + "Open /?e2e-role=owner on mobile viewport and sign in.", + "Navigate owner tabs with keyboard keys.", + "Verify tab selection and active panel on mobile layout." + ] + }, + { + "name": "owner-pantry-mobile-card-workflow", + "pass": true, + "repro": [ + "Open /?e2e-role=owner on mobile viewport and sign in.", + "Open Pantry tab and update Tomatoes through mobile card status select.", + "Verify pantry update success feedback is shown." ] } ], - "serverOutput": "\n> react-example@0.0.0 dev:e2e\n> vite --config test/e2e/vite.e2e.config.ts\n\n9:38:17 PM [vite] (client) Re-optimizing dependencies because vite config has changed\n\n VITE v6.4.1 ready in 425 ms\n\n ➜ Local: http://127.0.0.1:3000/\n" + "serverOutput": "\n> react-example@0.0.0 dev:e2e\n> vite --config test/e2e/vite.e2e.config.ts\n\n\n VITE v6.4.1 ready in 267 ms\n\n ➜ Local: http://127.0.0.1:3000/\n" } diff --git a/test/e2e/mocks/firebase-auth.ts b/test/e2e/mocks/firebase-auth.ts index 9ee7158..ff9dc2c 100644 --- a/test/e2e/mocks/firebase-auth.ts +++ b/test/e2e/mocks/firebase-auth.ts @@ -56,6 +56,13 @@ export async function signInWithPopup(auth: Auth, _provider?: GoogleAuthProvider return { user: nextUser }; } +export async function signInWithRedirect(auth: Auth, _provider?: GoogleAuthProvider): Promise { + const nextUser = createMockUser(resolveRequestedRole()); + saveSignedInUser(nextUser); + auth.currentUser = nextUser; + notifyAuthListeners(); +} + export async function signOut(auth: Auth): Promise { saveSignedInUser(null); auth.currentUser = null; diff --git a/test/e2e/run.mjs b/test/e2e/run.mjs index bc1976d..a2839d5 100644 --- a/test/e2e/run.mjs +++ b/test/e2e/run.mjs @@ -16,6 +16,39 @@ const pageViewport = { width: 1440, height: 1200, }; +const mobileViewport = { + width: 390, + height: 844, +}; + +function parseBooleanEnv(value, fallbackValue) { + if (value === undefined) { + return fallbackValue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === '1' || normalized === 'true' || normalized === 'yes') { + return true; + } + if (normalized === '0' || normalized === 'false' || normalized === 'no') { + return false; + } + + return fallbackValue; +} + +function parseNumberEnv(value) { + if (value === undefined || value.trim().length === 0) { + return undefined; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return undefined; + } + + return parsed; +} function sleep(durationMs) { return new Promise((resolve) => { @@ -100,7 +133,7 @@ async function resetBrowserState(page) { window.sessionStorage.clear(); }); await page.reload({ waitUntil: 'domcontentloaded' }); - await waitForText(page, 'Sign in with Google', uiTimeoutMs); + await page.waitForFunction(() => document.readyState === 'complete', { timeout: uiTimeoutMs }); } async function waitForText(page, expectedText, timeoutMs) { @@ -201,6 +234,41 @@ async function clickByTestId(page, testId) { await page.click(selector); } +async function setSelectByTestId(page, testId, nextValue) { + const selector = `[data-testid="${testId}"]`; + await page.waitForSelector(selector); + await page.evaluate( + ({ cssSelector, value }) => { + const select = document.querySelector(cssSelector); + if (!(select instanceof HTMLSelectElement)) { + throw new Error(`Select not found: ${cssSelector}`); + } + select.value = value; + select.dispatchEvent(new Event('change', { bubbles: true })); + }, + { cssSelector: selector, value: nextValue }, + ); +} + +async function waitForOwnerTabSelection(page, tabTestId, panelId, timeoutMs) { + const tabSelector = `[data-testid="${tabTestId}"]`; + const panelSelector = `#${panelId}`; + await page.waitForFunction( + ({ tabCss, panelCss }) => { + const tab = document.querySelector(tabCss); + const panel = document.querySelector(panelCss); + if (!(tab instanceof HTMLElement) || !(panel instanceof HTMLElement)) { + return false; + } + const isSelected = tab.getAttribute('aria-selected') === 'true'; + const isVisible = panel.hidden === false; + return isSelected && isVisible; + }, + { timeout: timeoutMs }, + { tabCss: tabSelector, panelCss: panelSelector }, + ); +} + async function setRowSelectValue(page, rowText, nextValue) { const didUpdate = await page.evaluate( ({ expectedRowText, value }) => { @@ -382,8 +450,8 @@ async function runOwnerCoreFlow(page) { ]; await page.goto(`${baseUrl}/?e2e-role=owner`, { waitUntil: 'domcontentloaded' }); - await waitForText(page, 'Sign in with Google', uiTimeoutMs); - await clickExactText(page, 'button', 'Sign in with Google'); + await page.waitForSelector('[data-testid="sign-in-button"]', { timeout: uiTimeoutMs }); + await clickByTestId(page, 'sign-in-button'); await waitForText(page, 'Owner View', uiTimeoutMs); await waitForText(page, 'Household Settings', uiTimeoutMs); await fillByTestId(page, 'meal-day-0-morning', 'E2E Poha and fruit'); @@ -444,15 +512,13 @@ async function runCookCoreFlow(page) { ]; await page.goto(`${baseUrl}/?e2e-role=cook`, { waitUntil: 'domcontentloaded' }); - await waitForText(page, 'Sign in with Google', uiTimeoutMs); - await clickExactText(page, 'button', 'Sign in with Google'); + await page.waitForSelector('[data-testid="sign-in-button"]', { timeout: uiTimeoutMs }); + await clickByTestId(page, 'sign-in-button'); await waitForText(page, 'Cook View', uiTimeoutMs); await waitForAnyText(page, ["Today's Menu", 'आज का मेनू'], uiTimeoutMs); await fillByTestId(page, 'cook-ai-input', 'Tamatar aur atta khatam ho gaya hai, dhania 2 bunch chahiye'); await clickByTestId(page, 'cook-ai-submit'); await waitForAnyText(page, ['Updated successfully!', 'अपडेट हो गया!'], uiTimeoutMs); - await waitForText(page, 'Dhania', uiTimeoutMs); - await waitForAnyText(page, ['On List', 'सूची में है'], uiTimeoutMs); await fillByTestId(page, 'cook-pantry-search', 'Tomatoes'); await waitForText(page, 'Tomatoes', uiTimeoutMs); @@ -481,8 +547,8 @@ async function runMalformedAiResponseCheck(page) { ]; await page.goto(`${baseUrl}/?e2e-role=cook`, { waitUntil: 'domcontentloaded' }); - await waitForText(page, 'Sign in with Google', uiTimeoutMs); - await clickExactText(page, 'button', 'Sign in with Google'); + await page.waitForSelector('[data-testid="sign-in-button"]', { timeout: uiTimeoutMs }); + await clickByTestId(page, 'sign-in-button'); await waitForText(page, 'Cook View', uiTimeoutMs); await waitForAnyText(page, ["Today's Menu", 'आज का मेनू'], uiTimeoutMs); await fillByTestId(page, 'cook-ai-input', '__e2e_malformed_ai__'); @@ -513,8 +579,8 @@ async function runUnmatchedItemWarningCheck(page) { ]; await page.goto(`${baseUrl}/?e2e-role=cook`, { waitUntil: 'domcontentloaded' }); - await waitForText(page, 'Sign in with Google', uiTimeoutMs); - await clickExactText(page, 'button', 'Sign in with Google'); + await page.waitForSelector('[data-testid="sign-in-button"]', { timeout: uiTimeoutMs }); + await clickByTestId(page, 'sign-in-button'); await waitForText(page, 'Cook View', uiTimeoutMs); await waitForAnyText(page, ["Today's Menu", 'आज का मेनू'], uiTimeoutMs); await fillByTestId(page, 'cook-ai-input', '__e2e_unmatched_item__'); @@ -542,8 +608,8 @@ async function runNoteSavePathCheck(page) { ]; await page.goto(`${baseUrl}/?e2e-role=cook`, { waitUntil: 'domcontentloaded' }); - await waitForText(page, 'Sign in with Google', uiTimeoutMs); - await clickExactText(page, 'button', 'Sign in with Google'); + await page.waitForSelector('[data-testid="sign-in-button"]', { timeout: uiTimeoutMs }); + await clickByTestId(page, 'sign-in-button'); await waitForText(page, 'Cook View', uiTimeoutMs); await waitForAnyText(page, ["Today's Menu", 'आज का मेनू'], uiTimeoutMs); await fillByTestId(page, 'cook-pantry-search', 'Atta'); @@ -568,8 +634,8 @@ async function runRemoveFromGroceryPathCheck(page) { ]; await page.goto(`${baseUrl}/?e2e-role=owner`, { waitUntil: 'domcontentloaded' }); - await waitForText(page, 'Sign in with Google', uiTimeoutMs); - await clickExactText(page, 'button', 'Sign in with Google'); + await page.waitForSelector('[data-testid="sign-in-button"]', { timeout: uiTimeoutMs }); + await clickByTestId(page, 'sign-in-button'); await waitForText(page, 'Owner View', uiTimeoutMs); await clickByTestId(page, 'owner-tab-grocery'); await waitForText(page, 'Tomatoes', uiTimeoutMs); @@ -584,6 +650,86 @@ async function runRemoveFromGroceryPathCheck(page) { }; } +async function runOwnerTabKeyboardNavigationCheck(page) { + const repro = [ + 'Open /?e2e-role=owner and sign in.', + 'Focus owner tabs and navigate with ArrowRight, End, and Home.', + 'Verify selected tab and panel mapping after each keypress.', + ]; + + await page.goto(`${baseUrl}/?e2e-role=owner`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('[data-testid="sign-in-button"]', { timeout: uiTimeoutMs }); + await clickByTestId(page, 'sign-in-button'); + await waitForText(page, 'Owner View', uiTimeoutMs); + + await page.focus('[data-testid="owner-tab-meals"]'); + await page.keyboard.press('ArrowRight'); + await waitForOwnerTabSelection(page, 'owner-tab-grocery', 'owner-panel-grocery', uiTimeoutMs); + await page.keyboard.press('End'); + await waitForOwnerTabSelection(page, 'owner-tab-pantry', 'owner-panel-pantry', uiTimeoutMs); + await page.keyboard.press('Home'); + await waitForOwnerTabSelection(page, 'owner-tab-meals', 'owner-panel-meals', uiTimeoutMs); + + return { + name: 'owner-tab-keyboard-navigation', + pass: true, + repro, + }; +} + +async function runOwnerTabKeyboardNavigationMobileCheck(page) { + const repro = [ + 'Open /?e2e-role=owner on mobile viewport and sign in.', + 'Navigate owner tabs with keyboard keys.', + 'Verify tab selection and active panel on mobile layout.', + ]; + + await page.setViewport(mobileViewport); + await page.goto(`${baseUrl}/?e2e-role=owner`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('[data-testid="sign-in-button"]', { timeout: uiTimeoutMs }); + await clickByTestId(page, 'sign-in-button'); + await waitForText(page, 'Owner View', uiTimeoutMs); + + await page.focus('[data-testid="owner-tab-meals"]'); + await page.keyboard.press('ArrowRight'); + await waitForOwnerTabSelection(page, 'owner-tab-grocery', 'owner-panel-grocery', uiTimeoutMs); + await page.keyboard.press('End'); + await waitForOwnerTabSelection(page, 'owner-tab-pantry', 'owner-panel-pantry', uiTimeoutMs); + await page.keyboard.press('Home'); + await waitForOwnerTabSelection(page, 'owner-tab-meals', 'owner-panel-meals', uiTimeoutMs); + + return { + name: 'owner-tab-keyboard-navigation-mobile', + pass: true, + repro, + }; +} + +async function runOwnerPantryMobileCardWorkflowCheck(page) { + const repro = [ + 'Open /?e2e-role=owner on mobile viewport and sign in.', + 'Open Pantry tab and update Tomatoes through mobile card status select.', + 'Verify pantry update success feedback is shown.', + ]; + + await page.setViewport(mobileViewport); + await page.goto(`${baseUrl}/?e2e-role=owner`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('[data-testid="sign-in-button"]', { timeout: uiTimeoutMs }); + await clickByTestId(page, 'sign-in-button'); + await waitForText(page, 'Owner View', uiTimeoutMs); + await clickByTestId(page, 'owner-tab-pantry'); + await waitForText(page, 'Pantry Management', uiTimeoutMs); + await setSelectByTestId(page, 'pantry-mobile-status-9', 'in-stock'); + await waitForText(page, 'Pantry status updated.', uiTimeoutMs); + await page.waitForSelector('[data-testid="pantry-mobile-card-9"]', { timeout: uiTimeoutMs }); + + return { + name: 'owner-pantry-mobile-card-workflow', + pass: true, + repro, + }; +} + async function runScenario(browser, scenario) { const page = await browser.newPage(); page.setDefaultTimeout(uiTimeoutMs); @@ -674,6 +820,33 @@ const scenarios = [ ], run: runRemoveFromGroceryPathCheck, }, + { + name: 'owner-tab-keyboard-navigation', + repro: [ + 'Open /?e2e-role=owner and sign in.', + 'Navigate owner tabs with keyboard arrows and Home/End.', + 'Verify tab and panel selection updates.', + ], + run: runOwnerTabKeyboardNavigationCheck, + }, + { + name: 'owner-tab-keyboard-navigation-mobile', + repro: [ + 'Open /?e2e-role=owner on mobile viewport and sign in.', + 'Navigate owner tabs with keyboard arrows and Home/End.', + 'Verify tab and panel selection updates on mobile.', + ], + run: runOwnerTabKeyboardNavigationMobileCheck, + }, + { + name: 'owner-pantry-mobile-card-workflow', + repro: [ + 'Open /?e2e-role=owner on mobile viewport and sign in.', + 'Open Pantry tab and use mobile card status control.', + 'Verify pantry update feedback appears.', + ], + run: runOwnerPantryMobileCardWorkflowCheck, + }, ]; async function main() { @@ -714,7 +887,9 @@ async function main() { const browser = await puppeteer.launch({ executablePath: resolveBrowserExecutablePath(), - headless: true, + headless: parseBooleanEnv(process.env.E2E_HEADLESS, true), + slowMo: parseNumberEnv(process.env.E2E_SLOW_MO), + devtools: parseBooleanEnv(process.env.E2E_DEVTOOLS, false), defaultViewport: pageViewport, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); diff --git a/test/e2e/vite.e2e.config.ts b/test/e2e/vite.e2e.config.ts index 0e1cd40..c7a77da 100644 --- a/test/e2e/vite.e2e.config.ts +++ b/test/e2e/vite.e2e.config.ts @@ -63,6 +63,8 @@ export default defineConfig({ plugins: [react(), tailwindcss(), aiParseMockPlugin], define: { 'process.env.GEMINI_API_KEY': JSON.stringify('e2e-mock-key'), + 'process.env.VITE_INGREDIENT_IMAGE_BASE_URL': JSON.stringify('https://example.com/ingredients'), + 'import.meta.env.VITE_INGREDIENT_IMAGE_BASE_URL': JSON.stringify('https://example.com/ingredients'), }, resolve: { alias: { diff --git a/test/rules/check-java.mjs b/test/rules/check-java.mjs index 85824f0..8a77dda 100644 --- a/test/rules/check-java.mjs +++ b/test/rules/check-java.mjs @@ -1,17 +1,56 @@ import { spawnSync } from 'node:child_process'; -const result = spawnSync('java', ['-version'], { encoding: 'utf-8' }); -if (result.status === 0) { - process.exit(0); +const MIN_JAVA_MAJOR = 21; + +function extractMajorVersion(output) { + const match = output.match(/version\s+"([^"]+)"/i); + if (!match) { + return null; + } + + const versionString = match[1]; + const firstSegment = versionString.split('.')[0]; + if (firstSegment === '1') { + const legacySegment = versionString.split('.')[1]; + if (!legacySegment) { + return null; + } + const parsedLegacy = Number.parseInt(legacySegment, 10); + return Number.isNaN(parsedLegacy) ? null : parsedLegacy; + } + + const parsed = Number.parseInt(firstSegment, 10); + return Number.isNaN(parsed) ? null : parsed; } +const result = spawnSync('java', ['-version'], { encoding: 'utf-8' }); const stderr = result.stderr ? result.stderr.trim() : ''; const stdout = result.stdout ? result.stdout.trim() : ''; const details = stderr.length > 0 ? stderr : stdout; -console.error('Java runtime is required for Firestore Emulator tests.'); -if (details.length > 0) { +if (result.status !== 0) { + console.error('Java runtime is required for Firestore Emulator tests.'); + if (details.length > 0) { + console.error(details); + } + console.error(`Install Java ${MIN_JAVA_MAJOR}+ and ensure \`java\` is available in PATH, then rerun \`npm run rules:test\`.`); + process.exit(1); +} + +const majorVersion = extractMajorVersion(details); +if (majorVersion === null) { + console.error('Unable to detect Java major version from `java -version` output.'); console.error(details); + console.error(`Install Java ${MIN_JAVA_MAJOR}+ and ensure \`java\` is available in PATH, then rerun \`npm run rules:test\`.`); + process.exit(1); } -console.error('Install Java 17+ and ensure `java` is available in PATH, then rerun `npm run rules:test`.'); -process.exit(1); + +if (majorVersion < MIN_JAVA_MAJOR) { + console.error(`Java ${MIN_JAVA_MAJOR}+ is required for Firestore Emulator tests.`); + console.error(`Detected Java major version: ${majorVersion}`); + console.error(details); + console.error(`Install Java ${MIN_JAVA_MAJOR}+ and ensure \`java\` is available in PATH, then rerun \`npm run rules:test\`.`); + process.exit(1); +} + +process.exit(0); diff --git a/test/rules/run.ts b/test/rules/run.ts index 263bef7..283ac9a 100644 --- a/test/rules/run.ts +++ b/test/rules/run.ts @@ -31,6 +31,7 @@ interface EmulatorHostPort { type TestAuthContext = ReturnType; type TestUnauthContext = ReturnType; type TestFirestore = ReturnType; +type InventoryStatusValue = 'in-stock' | 'low' | 'out'; const projectId = 'demo-rasoi-planner'; const ownerUid = 'owner-uid-1'; @@ -102,6 +103,33 @@ async function seedInventoryItem(testEnv: RulesTestEnvironment): Promise { }); } +interface InventorySeedInput { + itemId: string; + name: string; + status: InventoryStatusValue; + lastUpdated: string; + updatedBy: 'owner' | 'cook'; + requestedQuantity: string; +} + +async function seedInventoryItemWithStatus(testEnv: RulesTestEnvironment, input: InventorySeedInput): Promise { + await testEnv.withSecurityRulesDisabled(async (context) => { + const adminDb = context.firestore(); + await setDoc(doc(adminDb, 'households', householdId, 'inventory', input.itemId), { + name: input.name, + category: 'Staples', + status: input.status, + icon: '🍅', + lastUpdated: input.lastUpdated, + updatedBy: input.updatedBy, + defaultQuantity: '1kg', + requestedQuantity: input.requestedQuantity, + verificationNeeded: false, + anomalyReason: '', + }); + }); +} + async function seedLogItem(testEnv: RulesTestEnvironment): Promise { await testEnv.withSecurityRulesDisabled(async (context) => { const adminDb = context.firestore(); @@ -116,6 +144,21 @@ async function seedLogItem(testEnv: RulesTestEnvironment): Promise { }); } +async function seedUnknownQueueItem(testEnv: RulesTestEnvironment): Promise { + await testEnv.withSecurityRulesDisabled(async (context) => { + const adminDb = context.firestore(); + await setDoc(doc(adminDb, 'households', householdId, 'unknownIngredientQueue', 'queue-item-1'), { + name: 'Curry Leaves', + category: 'veggies', + status: 'open', + requestedStatus: 'low', + createdAt: '2026-03-25T09:00:00.000Z', + createdBy: 'cook', + requestedQuantity: '1 bunch', + }); + }); +} + async function testUnauthenticatedCannotReadOrWriteHousehold(testEnv: RulesTestEnvironment): Promise { const unauthDb = getUnauthenticatedDb(testEnv); await assertFails(getDoc(doc(unauthDb, 'households', householdId))); @@ -185,6 +228,25 @@ async function testInvitedCookCanReadHouseholdInventoryAndLogs(testEnv: RulesTes await assertSucceeds(getDocs(collection(cookDb, 'households', householdId, 'logs'))); } +async function testOwnerAndCookCanReadUnknownIngredientQueue(testEnv: RulesTestEnvironment): Promise { + await seedOwnerHousehold(testEnv, cookEmail); + await seedUnknownQueueItem(testEnv); + + const ownerDb = getAuthenticatedDb(testEnv, ownerUid, ownerEmail); + const cookDb = getAuthenticatedDb(testEnv, cookUid, cookEmail); + + await assertSucceeds(getDocs(collection(ownerDb, 'households', householdId, 'unknownIngredientQueue'))); + await assertSucceeds(getDocs(collection(cookDb, 'households', householdId, 'unknownIngredientQueue'))); +} + +async function testNonMemberCannotReadUnknownIngredientQueue(testEnv: RulesTestEnvironment): Promise { + await seedOwnerHousehold(testEnv, cookEmail); + await seedUnknownQueueItem(testEnv); + + const intruderDb = getAuthenticatedDb(testEnv, intruderUid, 'intruder@example.com'); + await assertFails(getDocs(collection(intruderDb, 'households', householdId, 'unknownIngredientQueue'))); +} + async function testInvitedCookCannotWriteMealsOrDeleteInventory(testEnv: RulesTestEnvironment): Promise { await seedOwnerHousehold(testEnv, cookEmail); await seedInventoryItem(testEnv); @@ -241,6 +303,112 @@ async function testInventoryAndLogWritesEnforceRules(testEnv: RulesTestEnvironme await assertFails(invalidBatch.commit()); } +async function testOwnerCanWriteMatchingInventoryAndLogTransition(testEnv: RulesTestEnvironment): Promise { + await seedOwnerHousehold(testEnv, cookEmail); + await seedInventoryItem(testEnv); + + const ownerDb = getAuthenticatedDb(testEnv, ownerUid, ownerEmail); + const timestamp = '2026-03-25T11:00:00.000Z'; + const batch = writeBatch(ownerDb); + batch.update(doc(ownerDb, 'households', householdId, 'inventory', 'tomatoes'), { + status: 'low', + lastUpdated: timestamp, + updatedBy: 'owner', + requestedQuantity: '2kg', + }); + batch.set(doc(ownerDb, 'households', householdId, 'logs', 'log-owner-low'), { + itemId: 'tomatoes', + itemName: 'Tomatoes', + oldStatus: 'in-stock', + newStatus: 'low', + timestamp, + role: 'owner', + }); + + await assertSucceeds(batch.commit()); +} + +async function testCookCanWriteMatchingInventoryAndLogTransition(testEnv: RulesTestEnvironment): Promise { + await seedOwnerHousehold(testEnv, cookEmail); + await seedInventoryItemWithStatus(testEnv, { + itemId: 'atta', + name: 'Atta', + status: 'low', + lastUpdated: '2026-03-24T08:00:00.000Z', + updatedBy: 'owner', + requestedQuantity: '5kg', + }); + + const cookDb = getAuthenticatedDb(testEnv, cookUid, cookEmail); + const timestamp = '2026-03-25T11:05:00.000Z'; + const batch = writeBatch(cookDb); + batch.update(doc(cookDb, 'households', householdId, 'inventory', 'atta'), { + status: 'out', + lastUpdated: timestamp, + updatedBy: 'cook', + requestedQuantity: '3kg', + }); + batch.set(doc(cookDb, 'households', householdId, 'logs', 'log-cook-out'), { + itemId: 'atta', + itemName: 'Atta', + oldStatus: 'low', + newStatus: 'out', + timestamp, + role: 'cook', + }); + + await assertSucceeds(batch.commit()); +} + +async function testMismatchedLogStatusIsRejected(testEnv: RulesTestEnvironment): Promise { + await seedOwnerHousehold(testEnv, cookEmail); + await seedInventoryItem(testEnv); + + const ownerDb = getAuthenticatedDb(testEnv, ownerUid, ownerEmail); + const timestamp = '2026-03-25T11:10:00.000Z'; + const batch = writeBatch(ownerDb); + batch.update(doc(ownerDb, 'households', householdId, 'inventory', 'tomatoes'), { + status: 'low', + lastUpdated: timestamp, + updatedBy: 'owner', + requestedQuantity: '2kg', + }); + batch.set(doc(ownerDb, 'households', householdId, 'logs', 'log-mismatch-status'), { + itemId: 'tomatoes', + itemName: 'Tomatoes', + oldStatus: 'in-stock', + newStatus: 'out', + timestamp, + role: 'owner', + }); + + await assertFails(batch.commit()); +} + +async function testUpdatedByRoleMismatchIsRejected(testEnv: RulesTestEnvironment): Promise { + await seedOwnerHousehold(testEnv, cookEmail); + await seedInventoryItem(testEnv); + + const cookDb = getAuthenticatedDb(testEnv, cookUid, cookEmail); + const batch = writeBatch(cookDb); + batch.update(doc(cookDb, 'households', householdId, 'inventory', 'tomatoes'), { + status: 'low', + lastUpdated: '2026-03-25T11:15:00.000Z', + updatedBy: 'owner', + requestedQuantity: '2kg', + }); + batch.set(doc(cookDb, 'households', householdId, 'logs', 'log-role-mismatch'), { + itemId: 'tomatoes', + itemName: 'Tomatoes', + oldStatus: 'in-stock', + newStatus: 'low', + timestamp: '2026-03-25T11:15:00.000Z', + role: 'cook', + }); + + await assertFails(batch.commit()); +} + async function testLegacyUsersPathRestrictedByUid(testEnv: RulesTestEnvironment): Promise { const userADb = getAuthenticatedDb(testEnv, userAUid, 'legacya@example.com'); const userBDb = getAuthenticatedDb(testEnv, userBUid, 'legacyb@example.com'); @@ -264,6 +432,49 @@ async function testLegacyUsersPathRestrictedByUid(testEnv: RulesTestEnvironment) ); } +async function testCookCanCreateUnknownQueueItemButCannotResolve(testEnv: RulesTestEnvironment): Promise { + await seedOwnerHousehold(testEnv, cookEmail); + const cookDb = getAuthenticatedDb(testEnv, cookUid, cookEmail); + + await assertSucceeds( + setDoc(doc(cookDb, 'households', householdId, 'unknownIngredientQueue', 'queue-item-cook'), { + name: 'Curry Leaves', + category: 'veggies', + status: 'open', + requestedStatus: 'low', + createdAt: '2026-03-25T10:00:00.000Z', + createdBy: 'cook', + requestedQuantity: '1 bunch', + }), + ); + + await seedUnknownQueueItem(testEnv); + await assertFails( + updateDoc(doc(cookDb, 'households', householdId, 'unknownIngredientQueue', 'queue-item-1'), { + status: 'resolved', + resolution: 'dismissed', + resolvedAt: '2026-03-25T10:05:00.000Z', + resolvedBy: 'cook', + }), + ); +} + +async function testOwnerCanResolveUnknownQueueItemWithValidTransition(testEnv: RulesTestEnvironment): Promise { + await seedOwnerHousehold(testEnv, cookEmail); + await seedUnknownQueueItem(testEnv); + const ownerDb = getAuthenticatedDb(testEnv, ownerUid, ownerEmail); + + await assertSucceeds( + updateDoc(doc(ownerDb, 'households', householdId, 'unknownIngredientQueue', 'queue-item-1'), { + status: 'resolved', + resolution: 'promoted', + resolvedAt: '2026-03-25T10:10:00.000Z', + resolvedBy: 'owner', + promotedInventoryItemId: 'inventory-123', + }), + ); +} + async function executeTestCase(testEnv: RulesTestEnvironment, testCase: TestCase): Promise { await testEnv.clearFirestore(); try { @@ -296,6 +507,14 @@ async function runAllTests(testEnv: RulesTestEnvironment): Promise { name: 'Invited cook can read household, inventory, and logs', run: testInvitedCookCanReadHouseholdInventoryAndLogs, }, + { + name: 'Owner and invited cook can read unknown ingredient queue', + run: testOwnerAndCookCanReadUnknownIngredientQueue, + }, + { + name: 'Non-member cannot read unknown ingredient queue', + run: testNonMemberCannotReadUnknownIngredientQueue, + }, { name: 'Invited cook cannot write meals or delete inventory', run: testInvitedCookCannotWriteMealsOrDeleteInventory, @@ -304,10 +523,34 @@ async function runAllTests(testEnv: RulesTestEnvironment): Promise { name: 'Inventory/log writes must satisfy rules constraints', run: testInventoryAndLogWritesEnforceRules, }, + { + name: 'Owner can write matching inventory/log transition', + run: testOwnerCanWriteMatchingInventoryAndLogTransition, + }, + { + name: 'Cook can write matching inventory/log transition', + run: testCookCanWriteMatchingInventoryAndLogTransition, + }, + { + name: 'Mismatched log status is rejected', + run: testMismatchedLogStatusIsRejected, + }, + { + name: 'Role mismatch on inventory updatedBy is rejected', + run: testUpdatedByRoleMismatchIsRejected, + }, { name: 'Legacy users path is restricted by uid', run: testLegacyUsersPathRestrictedByUid, }, + { + name: 'Cook can create unknown queue item but cannot resolve it', + run: testCookCanCreateUnknownQueueItemButCannotResolve, + }, + { + name: 'Owner can resolve unknown queue item with valid transition', + run: testOwnerCanResolveUnknownQueueItemWithValidTransition, + }, ]; for (const testCase of testCases) { diff --git a/test/unit/run.ts b/test/unit/run.ts index e963dbd..878f6af 100644 --- a/test/unit/run.ts +++ b/test/unit/run.ts @@ -1,12 +1,28 @@ import assert from 'node:assert/strict'; import { getAppCopy, getCookCopy, getOwnerCopy } from '../../src/i18n/copy'; +import { validateAiParseResult } from '../../src/services/aiValidation'; +import { buildPantryLog } from '../../src/services/logService'; +import { sanitizeFirestorePayload } from '../../src/utils/firestorePayload'; +import { getIngredientNativeContextLabel, resolveIngredientVisual } from '../../src/utils/ingredientVisuals'; import { + buildUnknownQueueTargetFingerprint, + classifyHouseholdMembershipProbe, + getUnknownQueueLoadErrorMessage, + isFirestoreFailedPreconditionError, + isFirestorePermissionDeniedError, + sortUnknownIngredientQueueItemsByCreatedAt, + toFirestoreListenerErrorInfo, +} from '../../src/utils/unknownQueue'; +import { + getLocalizedCategoryName, getPantryCategoryLabel, getPantryCategoryOptions, normalizePantryCategory, pantryCategoryMatchesSearch, } from '../../src/utils/pantryCategory'; +process.env.VITE_INGREDIENT_IMAGE_BASE_URL = 'https://cdn.example.com/rasoi/ingredients'; + function testPantryCategoryNormalization(): void { assert.equal(normalizePantryCategory('Spices'), 'spices'); assert.equal(normalizePantryCategory('Staples'), 'staples'); @@ -47,12 +63,477 @@ function testCopyCoverage(): void { assert.ok(cookHi.smartAssistant.length > 0); } +function testAiParseValidationAcceptsMixedResult(): void { + const result = validateAiParseResult({ + understood: true, + message: 'Applied updates.', + updates: [ + { + itemId: 'atta', + newStatus: 'low', + requestedQuantity: '4kg', + }, + { + itemId: 'tomatoes', + newStatus: 'out', + }, + ], + unlistedItems: [ + { + name: 'Jeera', + status: 'low', + category: 'Spices', + requestedQuantity: '200g', + }, + ], + }); + + assert.deepEqual(result, { + understood: true, + message: 'Applied updates.', + updates: [ + { + itemId: 'atta', + newStatus: 'low', + requestedQuantity: '4kg', + }, + { + itemId: 'tomatoes', + newStatus: 'out', + requestedQuantity: undefined, + }, + ], + unlistedItems: [ + { + name: 'Jeera', + status: 'low', + category: 'Spices', + requestedQuantity: '200g', + }, + ], + }); +} + +function testAiParseValidationRejectsInvalidUnderstoodType(): void { + assert.throws( + () => + validateAiParseResult({ + understood: 'true', + updates: [], + unlistedItems: [], + }), + /AI response missing understood boolean\./, + ); +} + +function testAiParseValidationRejectsOversizedRequestedQuantity(): void { + assert.throws( + () => + validateAiParseResult({ + understood: true, + updates: [ + { + itemId: 'atta', + newStatus: 'low', + requestedQuantity: 'x'.repeat(201), + }, + ], + unlistedItems: [], + }), + /AI update requestedQuantity is invalid\./, + ); +} + +function testLocalizedCategoryLabels(): void { + assert.equal(getLocalizedCategoryName('staples', 'en'), 'Main Ration'); + assert.equal(getLocalizedCategoryName('staples', 'hi'), 'मुख्य राशन'); + assert.equal(getLocalizedCategoryName('milk', 'hi'), 'डेयरी'); + assert.equal(getPantryCategoryLabel('milk').includes('डेयरी'), true); +} + +function withMockedRandomUUID(value: string, callback: () => T): T { + const originalCrypto = globalThis.crypto; + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + randomUUID: () => value, + }, + }); + + try { + return callback(); + } finally { + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: originalCrypto, + }); + } +} + +function testBuildPantryLogIsDeterministic(): void { + const log = withMockedRandomUUID('mock-log-id', () => + buildPantryLog({ + itemId: 'tomatoes', + itemName: 'Tomatoes', + oldStatus: 'in-stock', + newStatus: 'low', + role: 'owner', + timestampIso: '2026-03-25T10:00:00.000Z', + }), + ); + + assert.deepEqual(log, { + id: 'mock-log-id', + itemId: 'tomatoes', + itemName: 'Tomatoes', + oldStatus: 'in-stock', + newStatus: 'low', + timestamp: '2026-03-25T10:00:00.000Z', + role: 'owner', + }); +} + +function testSanitizeFirestorePayloadOmitsUndefinedFields(): void { + const payload = sanitizeFirestorePayload({ + status: 'low' as const, + lastUpdated: '2026-03-25T10:05:00.000Z', + updatedBy: 'cook' as const, + requestedQuantity: undefined, + anomalyReason: '', + }); + + assert.deepEqual(payload, { + status: 'low', + lastUpdated: '2026-03-25T10:05:00.000Z', + updatedBy: 'cook', + anomalyReason: '', + }); +} + +function testIngredientVisualCatalogMatch(): void { + const visual = resolveIngredientVisual({ + name: 'Turmeric (Haldi)', + nameHi: 'हल्दी', + category: 'spices', + }); + + assert.equal(visual.source, 'catalog-match'); + assert.equal(visual.fallbackIcon, '🟡'); + assert.equal(visual.altText.includes('turmeric'), true); + assert.equal(visual.catalogMatch?.canonicalName, 'Turmeric'); + assert.equal(visual.catalogMatch?.transliteration, 'Haldi'); + assert.equal(visual.catalogMatch?.nativeName, 'हल्दी'); + assert.equal(visual.imageUrl, 'https://cdn.example.com/rasoi/ingredients/turmeric.webp'); +} + +function testIngredientVisualMissingBaseUrlUsesFallbackWithoutThrowing(): void { + const previousValue = process.env.VITE_INGREDIENT_IMAGE_BASE_URL; + delete process.env.VITE_INGREDIENT_IMAGE_BASE_URL; + + try { + const visual = resolveIngredientVisual({ + name: 'Turmeric', + category: 'spices', + }); + + assert.equal(visual.source, 'catalog-match'); + assert.equal(visual.fallbackIcon, '🟡'); + assert.equal(visual.imageUrl, null); + } finally { + process.env.VITE_INGREDIENT_IMAGE_BASE_URL = previousValue; + } +} + +function testIngredientVisualInvalidBaseUrlUsesFallbackWithoutThrowing(): void { + const previousValue = process.env.VITE_INGREDIENT_IMAGE_BASE_URL; + process.env.VITE_INGREDIENT_IMAGE_BASE_URL = 'ftp://invalid'; + + try { + const visual = resolveIngredientVisual({ + name: 'Turmeric', + category: 'spices', + }); + + assert.equal(visual.source, 'catalog-match'); + assert.equal(visual.fallbackIcon, '🟡'); + assert.equal(visual.imageUrl, null); + } finally { + process.env.VITE_INGREDIENT_IMAGE_BASE_URL = previousValue; + } +} + +function testIngredientVisualHindiMatch(): void { + const visual = resolveIngredientVisual({ + name: 'Requested item', + nameHi: 'नमक', + category: 'other', + }); + + assert.equal(visual.source, 'catalog-match'); + assert.equal(visual.fallbackIcon, '🧂'); + assert.equal(visual.altText.includes('salt'), true); + assert.equal(visual.catalogMatch?.canonicalName, 'Salt'); + assert.equal(visual.catalogMatch?.matchedKeyword, 'नमक'); +} + +function testExpandedIngredientVisualCoverage(): void { + const cases = [ + { input: { name: 'Jeera', category: 'spices' }, expectedKey: 'cumin', expectedName: 'Cumin Seeds', expectedNativeName: 'जीरा' }, + { input: { name: 'Sarson ka tel', category: 'staples' }, expectedKey: 'mustard-oil', expectedName: 'Mustard Oil', expectedNativeName: 'सरसों का तेल' }, + { input: { name: 'Rajma', category: 'pulses' }, expectedKey: 'rajma', expectedName: 'Kidney Beans', expectedNativeName: 'राजमा' }, + { input: { name: 'दही', category: 'dairy' }, expectedKey: 'curd', expectedName: 'Curd', expectedNativeName: 'दही' }, + { input: { name: 'Hara Dhaniya', category: 'veggies' }, expectedKey: 'coriander-leaves', expectedName: 'Coriander Leaves', expectedNativeName: 'हरा धनिया' }, + { input: { name: 'Besan', category: 'staples' }, expectedKey: 'besan', expectedName: 'Gram Flour', expectedNativeName: 'बेसन' }, + ] as const; + + for (const testCase of cases) { + const visual = resolveIngredientVisual(testCase.input); + + assert.equal(visual.source, 'catalog-match'); + assert.equal(visual.catalogMatch?.key, testCase.expectedKey); + assert.equal(visual.catalogMatch?.canonicalName, testCase.expectedName); + assert.equal(visual.catalogMatch?.nativeName, testCase.expectedNativeName); + } +} + +function testIngredientVisualChoosesMostSpecificKeyword(): void { + const visual = resolveIngredientVisual({ + name: 'Kala Namak', + category: 'spices', + }); + + assert.equal(visual.source, 'catalog-match'); + assert.equal(visual.catalogMatch?.key, 'black-salt'); + assert.equal(visual.catalogMatch?.canonicalName, 'Black Salt'); +} + +function testIngredientNativeContextLabel(): void { + const visual = resolveIngredientVisual({ + name: 'Jeera', + category: 'spices', + }); + + assert.equal(getIngredientNativeContextLabel({}, visual), 'Jeera / जीरा'); + assert.equal(getIngredientNativeContextLabel({ nameHi: 'जीरा' }, visual), 'Jeera / जीरा'); +} + +function testIngredientVisualExistingIconFallback(): void { + const visual = resolveIngredientVisual({ + name: 'Future Pantry Item', + category: 'spices', + icon: '🌿', + }); + + assert.equal(visual.source, 'existing-icon'); + assert.equal(visual.imageUrl, null); + assert.equal(visual.fallbackIcon, '🌿'); + assert.equal(visual.catalogMatch, undefined); +} + +function testIngredientVisualCategoryFallback(): void { + const visual = resolveIngredientVisual({ + name: 'Unknown packet', + category: 'other', + }); + + assert.equal(visual.source, 'category-fallback'); + assert.equal(visual.imageUrl, null); + assert.equal(visual.fallbackIcon, '📦'); + assert.equal(visual.catalogMatch, undefined); +} + +function testUnknownQueueErrorParsingAndMessaging(): void { + const permissionDenied = toFirestoreListenerErrorInfo({ + code: 'permission-denied', + message: 'Missing or insufficient permissions.', + name: 'FirebaseError', + }); + assert.equal(isFirestorePermissionDeniedError(permissionDenied), true); + assert.equal( + getUnknownQueueLoadErrorMessage(permissionDenied, 'owner'), + 'Unknown ingredient queue access denied. Firestore target mismatch suspected. Verify project/database rules deployment.', + ); + assert.equal( + getUnknownQueueLoadErrorMessage(permissionDenied, 'non-member'), + 'Unknown ingredient queue access denied. Household membership mismatch suspected.', + ); + + const failedPrecondition = toFirestoreListenerErrorInfo({ + code: 'failed-precondition', + message: 'The query requires an index.', + name: 'FirebaseError', + }); + assert.equal(isFirestoreFailedPreconditionError(failedPrecondition), true); + assert.equal( + getUnknownQueueLoadErrorMessage(failedPrecondition, 'owner'), + 'Unknown ingredient queue index is missing. Showing fallback order while index is provisioned.', + ); + + const unknownError = toFirestoreListenerErrorInfo(new Error('boom')); + assert.equal(isFirestoreFailedPreconditionError(unknownError), false); + assert.equal(isFirestorePermissionDeniedError(unknownError), false); + assert.equal(getUnknownQueueLoadErrorMessage(unknownError, null), 'Failed to load unknown ingredient queue.'); +} + +function testUnknownQueueFallbackSortOrder(): void { + const sorted = sortUnknownIngredientQueueItemsByCreatedAt([ + { + id: 'queue-2', + name: 'A', + category: 'spices', + status: 'open', + requestedStatus: 'low', + createdAt: '2026-03-25T08:00:00.000Z', + createdBy: 'cook', + }, + { + id: 'queue-1', + name: 'B', + category: 'staples', + status: 'open', + requestedStatus: 'low', + createdAt: '2026-03-25T10:00:00.000Z', + createdBy: 'owner', + }, + { + id: 'queue-3', + name: 'C', + category: 'veggies', + status: 'open', + requestedStatus: 'out', + createdAt: 'invalid-date', + createdBy: 'cook', + }, + ]); + + assert.deepEqual(sorted.map((item) => item.id), ['queue-1', 'queue-2', 'queue-3']); +} + +function testUnknownQueueTargetFingerprintAndMembershipProbe(): void { + const fingerprint = buildUnknownQueueTargetFingerprint({ + projectId: 'project-x', + databaseId: 'db-y', + householdId: 'house-z', + }); + assert.equal(fingerprint, 'project-x/db-y/households/house-z/unknownIngredientQueue'); + + assert.equal( + classifyHouseholdMembershipProbe({ + householdExists: true, + householdOwnerId: 'owner-1', + householdCookEmail: 'cook@example.com', + userUid: 'owner-1', + userEmail: 'owner@example.com', + }), + 'owner', + ); + assert.equal( + classifyHouseholdMembershipProbe({ + householdExists: true, + householdOwnerId: 'owner-1', + householdCookEmail: 'cook@example.com', + userUid: 'cook-uid', + userEmail: 'cook@example.com', + }), + 'cook', + ); + assert.equal( + classifyHouseholdMembershipProbe({ + householdExists: true, + householdOwnerId: 'owner-1', + householdCookEmail: 'cook@example.com', + userUid: 'intruder', + userEmail: 'intruder@example.com', + }), + 'non-member', + ); + assert.equal( + classifyHouseholdMembershipProbe({ + householdExists: false, + householdOwnerId: null, + householdCookEmail: null, + userUid: 'owner-1', + userEmail: 'owner@example.com', + }), + 'household-missing', + ); +} + function run(): void { testPantryCategoryNormalization(); testPantryCategoryLabels(); testPantryCategoryOptions(); testCopyCoverage(); + testAiParseValidationAcceptsMixedResult(); + testAiParseValidationRejectsInvalidUnderstoodType(); + testAiParseValidationRejectsOversizedRequestedQuantity(); + testLocalizedCategoryLabels(); + testBuildPantryLogIsDeterministic(); + testSanitizeFirestorePayloadOmitsUndefinedFields(); + testIngredientVisualCatalogMatch(); + testIngredientVisualMissingBaseUrlUsesFallbackWithoutThrowing(); + testIngredientVisualInvalidBaseUrlUsesFallbackWithoutThrowing(); + testIngredientVisualHindiMatch(); + testExpandedIngredientVisualCoverage(); + testIngredientVisualChoosesMostSpecificKeyword(); + testIngredientNativeContextLabel(); + testIngredientVisualExistingIconFallback(); + testIngredientVisualCategoryFallback(); + testUnknownQueueErrorParsingAndMessaging(); + testUnknownQueueFallbackSortOrder(); + testUnknownQueueTargetFingerprintAndMembershipProbe(); console.log('All unit tests passed.'); } run(); +function testIngredientVisualMixedLanguageCuminMatch(): void { + const visual = resolveIngredientVisual({ + name: 'Cumin Seeds', + nameHi: 'जीरा', + category: 'other', + }); + + assert.equal(visual.source, 'catalog-match'); + assert.equal(visual.fallbackIcon, '🟤'); + assert.equal(visual.altText.includes('cumin'), true); +} + +function testIngredientVisualFutureIngredientFallback(): void { + const visual = resolveIngredientVisual({ + name: 'Curry Leaves', + nameHi: 'करी पत्ता', + category: 'veggies', + }); + + assert.equal(visual.source, 'category-fallback'); + assert.equal(visual.imageUrl, null); + assert.equal(visual.fallbackIcon, '🥕'); +} + +function testIngredientVisualPunctuatedVariantMatch(): void { + const visual = resolveIngredientVisual({ + name: 'Red Chilli Powder (Kashmiri)', + nameHi: 'लाल मिर्च', + category: 'spices', + }); + + assert.equal(visual.source, 'catalog-match'); + assert.equal(visual.fallbackIcon, '🌶️'); + assert.equal(visual.altText.includes('red chilli'), true); +} + +function testPantryCategoryMatchesMixedLanguageQueries(): void { + assert.equal(pantryCategoryMatchesSearch('veggies', 'sabzi'), true); + assert.equal(pantryCategoryMatchesSearch('veggies', 'सब्ज़ियाँ'), true); + assert.equal(pantryCategoryMatchesSearch('staples', 'main ration'), true); +} + +function runQAFollowUpTests(): void { + testIngredientVisualMixedLanguageCuminMatch(); + testIngredientVisualFutureIngredientFallback(); + testIngredientVisualPunctuatedVariantMatch(); + testPantryCategoryMatchesMixedLanguageQueries(); + console.log('QA follow-up unit tests passed.'); +} + +runQAFollowUpTests();