From d5e38dec5a3b92db9ec3b94ad5a16aa3787ef7ab Mon Sep 17 00:00:00 2001 From: shadowdevcode Date: Tue, 24 Mar 2026 15:27:25 +0530 Subject: [PATCH] feat(ui): finalize responsive workspace refresh --- .github/workflows/ci.yml | 33 +++ .gitignore | 1 + .husky/pre-push | 3 + README.md | 45 ++++ api/ai/parse.ts | 204 ++++++++++------- api/ai/validation.ts | 137 ++++++++++++ package-lock.json | 17 ++ package.json | 4 +- src/App.tsx | 77 +++++-- src/components/CookView.tsx | 397 ++++++++++++++++++--------------- src/components/GroceryList.tsx | 60 +++-- src/components/MealPlanner.tsx | 164 ++++++++------ src/components/OwnerView.tsx | 64 +++--- src/components/Pantry.tsx | 320 ++++++++++++++------------ vite.config.ts | 24 +- 15 files changed, 1020 insertions(+), 530 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100755 .husky/pre-push create mode 100644 api/ai/validation.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..827ee6b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify-local: + name: verify-local + runs-on: ubuntu-latest + timeout-minutes: 30 + + 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: Run full verification + run: npm run verify:local diff --git a/.gitignore b/.gitignore index 5a86d2a..ac3f474 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ coverage/ *.log .env* !.env.example +.vercel diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..510b82b --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +npm run verify:local diff --git a/README.md b/README.md index ba12590..a0c8e98 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,51 @@ These constraints are enforced in `firestore.rules` and validated by `test/rules - Local emulator config: `firebase.json` - Confirm production Firebase Auth domain setup before release (Google provider and authorized domains) +## GitHub-Vercel Sync Workflow + +This project uses GitHub as the deployment source of truth. + +### Daily flow +1. Create a feature branch from `main`. +2. Commit and push branch changes. +3. Open a pull request. +4. Wait for CI check `verify-local` to pass. +5. Merge PR into `main`. +6. Vercel auto-deploys merged `main` commit to production. + +### CI contract +- Workflow file: `.github/workflows/ci.yml` +- Triggers: + - every pull request + - every push to `main` +- Required check name for branch protection: `verify-local` +- CI command chain: + - `npm ci` + - `npm run verify:local` + +### Local push gate (Husky) +- Husky install hook is configured via `npm run prepare`. +- Pre-push hook path: `.husky/pre-push` +- Pre-push command: `npm run verify:local` +- If checks fail, push is blocked. + +### Required GitHub settings (`main` branch protection) +- Require pull request before merging. +- Require status checks to pass before merging. +- Add required status check: `verify-local`. +- Require branches to be up to date before merging. + +### Required Vercel settings +- Git repository connected to this GitHub repo. +- Production branch set to `main`. +- Preview deployments enabled for pull requests. +- `GEMINI_API_KEY` configured for Preview and Production environments. + +### Emergency rollback +- Open Vercel dashboard. +- Find the last known-good production deployment. +- Redeploy that deployment to production. + ## Troubleshooting ### `GEMINI_API_KEY is not configured for the AI parse endpoint` diff --git a/api/ai/parse.ts b/api/ai/parse.ts index 92ac1c5..857f47a 100644 --- a/api/ai/parse.ts +++ b/api/ai/parse.ts @@ -1,6 +1,4 @@ -import { GoogleGenAI, Type } from '@google/genai'; -import { validateAiParseResult } from '../../src/services/aiValidation'; -import { AiParseResult, InventoryItem, Language } from '../../src/types'; +import { AiParseResult, InventoryPromptItem, Language, validateAiParseResult } from './validation.js'; type ParseCookVoiceInputRequest = { input: string; @@ -8,12 +6,21 @@ type ParseCookVoiceInputRequest = { lang: Language; }; -type InventoryPromptItem = Pick; +type NodeApiRequest = { + method?: string; + body?: unknown; +}; + +type NodeApiResponse = { + status: (statusCode: number) => NodeApiResponse; + json: (body: unknown) => void; +}; -const AI_MODEL = 'gemini-3-flash-preview'; const AI_ENDPOINT_NAME = 'ai_parse'; const MAX_AI_ATTEMPTS = 3; const BASE_RETRY_DELAY_MS = 250; +const AI_REQUEST_TIMEOUT_MS = 12000; +const GEMINI_API_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models'; const EMPTY_AI_RESPONSE_MESSAGE = 'Empty response'; class AiParseRequestError extends Error { @@ -37,8 +44,8 @@ class AiParseExecutionError extends Error { } } -function createJsonResponse(body: unknown, status: number): Response { - return Response.json(body, { status }); +function sendJsonResponse(response: NodeApiResponse, body: unknown, status: number): void { + response.status(status).json(body); } function getEnvApiKey(): string { @@ -49,8 +56,8 @@ function getEnvApiKey(): string { return apiKey; } -function getAiClient(): GoogleGenAI { - return new GoogleGenAI({ apiKey: getEnvApiKey() }); +function getAiModel(): string { + return process.env.GEMINI_MODEL ?? 'gemini-2.5-flash'; } function isLanguage(value: unknown): value is Language { @@ -71,11 +78,25 @@ function isInventoryPromptItem(value: unknown): value is InventoryPromptItem { } function parseRequestBody(raw: unknown): ParseCookVoiceInputRequest { - if (!raw || typeof raw !== 'object') { + const parsedRaw = (() => { + if (typeof raw !== 'string') { + return raw; + } + + try { + return JSON.parse(raw) as unknown; + } catch (error) { + throw new AiParseRequestError('AI parse request body must be valid JSON.', { + cause: error instanceof Error ? error : undefined, + }); + } + })(); + + if (!parsedRaw || typeof parsedRaw !== 'object') { throw new AiParseRequestError('AI parse request body must be an object.'); } - const candidate = raw as Record; + const candidate = parsedRaw as Record; if (typeof candidate.input !== 'string' || candidate.input.trim().length === 0) { throw new AiParseRequestError('AI parse request input must be a non-empty string.'); } @@ -114,42 +135,6 @@ function buildPrompt(input: string, inventory: InventoryPromptItem[], lang: Lang Return a JSON object matching this schema.`; } -function createResponseSchema() { - return { - type: Type.OBJECT, - properties: { - understood: { type: Type.BOOLEAN }, - message: { type: Type.STRING }, - updates: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - itemId: { type: Type.STRING }, - newStatus: { type: Type.STRING }, - requestedQuantity: { type: Type.STRING }, - }, - required: ['itemId', 'newStatus'], - }, - }, - unlistedItems: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - name: { type: Type.STRING }, - status: { type: Type.STRING }, - category: { type: Type.STRING }, - requestedQuantity: { type: Type.STRING }, - }, - required: ['name', 'status', 'category'], - }, - }, - }, - required: ['understood', 'updates', 'unlistedItems'], - }; -} - function getErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; @@ -179,26 +164,96 @@ async function waitForRetry(delayMs: number): Promise { }); } +function createTimeoutError(timeoutMs: number): Error { + return new Error(`AI request timed out after ${timeoutMs}ms.`); +} + +function buildGeminiEndpoint(model: string, apiKey: string): string { + return `${GEMINI_API_BASE_URL}/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`; +} + +function createGeminiRequestBody(prompt: string): Record { + return { + contents: [ + { + role: 'user', + parts: [{ text: prompt }], + }, + ], + generationConfig: { + responseMimeType: 'application/json', + }, + }; +} + +function parseGeminiText(raw: unknown): string { + if (!raw || typeof raw !== 'object') { + throw new Error('Gemini response body is not an object.'); + } + + const parsed = raw as { + candidates?: Array<{ + content?: { + parts?: Array<{ text?: string }>; + }; + }>; + }; + + const text = parsed.candidates?.[0]?.content?.parts?.[0]?.text; + if (typeof text !== 'string' || text.trim().length === 0) { + throw new Error(EMPTY_AI_RESPONSE_MESSAGE); + } + + return text; +} + +async function requestGeminiJson(prompt: string, apiKey: string, model: string, timeoutMs: number): Promise { + const abortController = new AbortController(); + const timeoutId = setTimeout(() => { + abortController.abort(); + }, timeoutMs); + + try { + const response = await fetch(buildGeminiEndpoint(model, apiKey), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(createGeminiRequestBody(prompt)), + signal: abortController.signal, + }); + + const responseBody = await response.text(); + + if (!response.ok) { + throw new Error( + `Gemini request failed. status=${response.status} body=${responseBody.slice(0, 1000)}` + ); + } + + const parsed = JSON.parse(responseBody) as unknown; + const text = parseGeminiText(parsed); + return JSON.parse(text) as unknown; + } catch (error) { + const candidate = error as { name?: string }; + if (candidate?.name === 'AbortError') { + throw createTimeoutError(timeoutMs); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} + async function generateAiParseResult(input: string, inventory: InventoryPromptItem[], lang: Language): Promise { - const aiClient = getAiClient(); + const apiKey = getEnvApiKey(); + const aiModel = getAiModel(); + const prompt = buildPrompt(input, inventory, lang); let lastError: unknown = null; for (let attempt = 1; attempt <= MAX_AI_ATTEMPTS; attempt += 1) { try { - const response = await aiClient.models.generateContent({ - model: AI_MODEL, - contents: buildPrompt(input, inventory, lang), - config: { - responseMimeType: 'application/json', - responseSchema: createResponseSchema(), - }, - }); - - if (!response.text) { - throw new Error(EMPTY_AI_RESPONSE_MESSAGE); - } - - const parsed = JSON.parse(response.text) as unknown; + const parsed = await requestGeminiJson(prompt, apiKey, aiModel, AI_REQUEST_TIMEOUT_MS); return validateAiParseResult(parsed); } catch (error) { lastError = error; @@ -222,25 +277,18 @@ export const config = { runtime: 'nodejs', }; -export default async function handler(request: Request): Promise { +export default async function handler(request: NodeApiRequest, response: NodeApiResponse): Promise { if (request.method !== 'POST') { - return createJsonResponse({ message: 'Method not allowed.' }, 405); + sendJsonResponse(response, { message: 'Method not allowed.' }, 405); + return; } try { - let body: unknown; - - try { - body = (await request.json()) as unknown; - } catch (error) { - throw new AiParseRequestError('AI parse request body must be valid JSON.', { - cause: error instanceof Error ? error : undefined, - }); - } - + const body = request.body; const { input, inventory, lang } = parseRequestBody(body); const result = await generateAiParseResult(input, inventory, lang); - return createJsonResponse(result, 200); + sendJsonResponse(response, result, 200); + return; } catch (error) { const errorMessage = getErrorMessage(error); const status = @@ -254,6 +302,10 @@ export default async function handler(request: Request): Promise { errorMessage, }); - return createJsonResponse({ message: status === 400 || status === 503 ? errorMessage : 'Could not process AI response safely. Please retry with clearer input.' }, status); + sendJsonResponse( + response, + { message: status === 400 || status === 503 ? errorMessage : 'Could not process AI response safely. Please retry with clearer input.' }, + status + ); } } diff --git a/api/ai/validation.ts b/api/ai/validation.ts new file mode 100644 index 0000000..1697994 --- /dev/null +++ b/api/ai/validation.ts @@ -0,0 +1,137 @@ +export type Language = 'en' | 'hi'; +export type InventoryStatus = 'in-stock' | 'low' | 'out'; + +export type InventoryPromptItem = { + id: string; + name: string; + nameHi?: string; +}; + +export type AiParseResult = { + understood: boolean; + message?: string; + updates: { + itemId: string; + newStatus: InventoryStatus; + requestedQuantity?: string; + }[]; + unlistedItems: { + name: string; + status: InventoryStatus; + category: string; + requestedQuantity?: string; + }[]; +}; + +const VALID_STATUSES: InventoryStatus[] = ['in-stock', 'low', 'out']; +const MAX_TEXT_LENGTH = 200; + +function isInventoryStatus(value: unknown): value is InventoryStatus { + return typeof value === 'string' && VALID_STATUSES.includes(value as InventoryStatus); +} + +function normalizeOptionalText(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + + if (typeof value === 'string') { + return value.length <= MAX_TEXT_LENGTH ? value : value.slice(0, MAX_TEXT_LENGTH); + } + + if (typeof value === 'number' || typeof value === 'boolean') { + const normalized = String(value); + return normalized.length <= MAX_TEXT_LENGTH ? normalized : normalized.slice(0, MAX_TEXT_LENGTH); + } + + return undefined; +} + +function normalizeOptionalMessage(value: unknown): string | undefined { + return normalizeOptionalText(value); +} + +function normalizeArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +export function validateAiParseResult(raw: unknown): AiParseResult { + if (!raw || typeof raw !== 'object') { + throw new Error('AI response was not an object.'); + } + + const candidate = raw as Record; + if (typeof candidate.understood !== 'boolean') { + throw new Error('AI response missing understood boolean.'); + } + + const message = normalizeOptionalMessage(candidate.message); + const rawUpdates = normalizeArray(candidate.updates); + const rawUnlistedItems = normalizeArray(candidate.unlistedItems); + + const updates = rawUpdates.map((updateItem) => { + if (!updateItem || typeof updateItem !== 'object') { + throw new Error('AI update item is invalid.'); + } + + const parsed = updateItem as Record; + if (typeof parsed.itemId !== 'string' || parsed.itemId.trim().length === 0) { + throw new Error('AI update itemId is invalid.'); + } + + if (!isInventoryStatus(parsed.newStatus)) { + throw new Error('AI update status is invalid.'); + } + + const requestedQuantity = normalizeOptionalText(parsed.requestedQuantity); + + return { + itemId: parsed.itemId, + newStatus: parsed.newStatus, + requestedQuantity, + }; + }); + + const unlistedItems = rawUnlistedItems.map((item) => { + if (!item || typeof item !== 'object') { + throw new Error('AI unlisted item is invalid.'); + } + + const parsed = item as Record; + if ( + typeof parsed.name !== 'string' || + parsed.name.trim().length === 0 || + parsed.name.length > MAX_TEXT_LENGTH + ) { + throw new Error('AI unlisted item name is invalid.'); + } + + if (!isInventoryStatus(parsed.status)) { + throw new Error('AI unlisted item status is invalid.'); + } + + if ( + typeof parsed.category !== 'string' || + parsed.category.trim().length === 0 || + parsed.category.length > MAX_TEXT_LENGTH + ) { + throw new Error('AI unlisted item category is invalid.'); + } + + const requestedQuantity = normalizeOptionalText(parsed.requestedQuantity); + + return { + name: parsed.name, + status: parsed.status, + category: parsed.category, + requestedQuantity, + }; + }); + + return { + understood: candidate.understood, + message, + updates, + unlistedItems, + }; +} diff --git a/package-lock.json b/package-lock.json index b4a2364..d15606b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.21", "firebase-tools": "^15.11.0", + "husky": "^9.1.7", "puppeteer": "^24.40.0", "tailwindcss": "^4.1.14", "tsx": "^4.21.0", @@ -7396,6 +7397,22 @@ "node": ">= 14" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", diff --git a/package.json b/package.json index 60c1054..f3d1dbb 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "lint": "tsc --noEmit", "rules:test": "node test/rules/check-java.mjs && firebase emulators:exec --only firestore --project demo-rasoi-planner \"tsx test/rules/run.ts\"", "e2e": "node test/e2e/run.mjs", - "verify:local": "npm run lint && npm run build && npm run rules:test && npm run e2e" + "verify:local": "npm run lint && npm run build && npm run rules:test && npm run e2e", + "prepare": "husky" }, "dependencies": { "@google/genai": "^1.29.0", @@ -35,6 +36,7 @@ "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.21", "firebase-tools": "^15.11.0", + "husky": "^9.1.7", "puppeteer": "^24.40.0", "tailwindcss": "^4.1.14", "tsx": "^4.21.0", diff --git a/src/App.tsx b/src/App.tsx index e43e862..9a17514 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,6 +46,10 @@ export default function App() { const [inviteEmail, setInviteEmail] = useState(''); const [isInviting, setIsInviting] = useState(false); const [uiFeedback, setUiFeedback] = useState(null); + const isOwner = role === 'owner'; + 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`; useEffect(() => { const unsubscribe = onAuthStateChanged(auth, (currentUser) => { @@ -71,6 +75,22 @@ export default function App() { let inventoryUnsub: Unsubscribe | null = null; let mealsUnsub: Unsubscribe | null = null; let logsUnsub: Unsubscribe | null = null; + let hasLoadedHousehold = false; + let hasLoadedInventory = false; + let hasLoadedMeals = false; + let hasResolvedInitialView = false; + + const markInitialViewReady = (): void => { + if (hasResolvedInitialView) { + return; + } + + if (hasLoadedHousehold && hasLoadedInventory && hasLoadedMeals) { + hasResolvedInitialView = true; + setIsDataLoaded(true); + setIsAuthReady(true); + } + }; const initialize = async (): Promise => { try { @@ -88,6 +108,8 @@ export default function App() { const data = snapshot.data() as HouseholdData; setHouseholdData(data); + hasLoadedHousehold = true; + markInitialViewReady(); if (resolved.role === 'cook') { const normalizedUserEmail = user.email?.toLowerCase() ?? ''; @@ -112,6 +134,8 @@ export default function App() { ...(itemDoc.data() as Omit), })); setInventory(items); + hasLoadedInventory = true; + markInitialViewReady(); }, (error) => { console.error('inventory_snapshot_failed', { error, householdId: resolved.householdId }); @@ -127,6 +151,8 @@ export default function App() { mealData[mealDoc.id] = mealDoc.data() as MealPlan; }); setMeals(mealData); + hasLoadedMeals = true; + markInitialViewReady(); }, (error) => { console.error('meals_snapshot_failed', { error, householdId: resolved.householdId }); @@ -142,8 +168,6 @@ export default function App() { ...(logDoc.data() as Omit), })); setLogs(nextLogs); - setIsDataLoaded(true); - setIsAuthReady(true); }, (error) => { console.error('logs_snapshot_failed', { error, householdId: resolved.householdId }); @@ -379,31 +403,38 @@ export default function App() { return (
-
-
-
- -

Rasoi Planner

-
-
-
- - {role === 'owner' ? : } - {role === 'owner' ? 'Owner View' : 'Cook View'} - +
+
+
+
+
+ +
+
+

Rasoi Planner

+

+ {isOwner ? 'Owner workspace' : 'Cook workspace'} +

+
+
+
+
+ {isOwner ? : } + {isOwner ? 'Owner' : 'Cook'} +
+
-
{uiFeedback && ( -
+
+

Household Settings

@@ -459,7 +490,7 @@ export default function App() {
)} -
+
{!isDataLoaded ? (
diff --git a/src/components/CookView.tsx b/src/components/CookView.tsx index 80188d6..490910a 100644 --- a/src/components/CookView.tsx +++ b/src/components/CookView.tsx @@ -152,48 +152,118 @@ export default function CookView({ meals, inventory, onUpdateInventory, onAddUnl }; return ( -
- {/* Header Controls */} -
- -
+
+
+
+
+

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

+

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

+

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

+
+
+ + + Cook View + +
+ +
+
- {/* Today's Menu */} -
-

{t.todayMenu}

-
- {/* Morning + Lunch */} -
-
- -

{t.morning}

+
+
+
+ +
+
+

Smart Assistant

+

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

+
+
+
+
+ setAiInput(e.target.value)} + placeholder={t.voicePrompt} + className="min-w-0 flex-1 rounded-xl border border-white/20 bg-white px-4 py-3 text-stone-800 outline-none transition focus:ring-2 focus:ring-orange-200" + disabled={isProcessing} + /> + +
+

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

+ {errorMessage && ( +
+
+ + {errorMessage} +
-

+ )} +

+
+ +
+
+
+

+ {lang === 'hi' ? 'आज का मेनू' : 'Today'} +

+

{t.todayMenu}

+
+
+
+
+
+
+ +
+

{t.morning}

+
+

{todaysMeals.morning || t.notPlanned}

- {(todaysMeals.notes || todaysMeals.leftovers) && ( -
+
{todaysMeals.notes && ( -
- +
+
- {t.notes}: + {t.notes}: {todaysMeals.notes}
)} {todaysMeals.leftovers && ( -
- +
+
- {t.leftovers}: + {t.leftovers}: {todaysMeals.leftovers}
@@ -202,177 +272,146 @@ export default function CookView({ meals, inventory, onUpdateInventory, onAddUnl )}
- {/* Evening */} -
-
- -

{t.evening}

+
+
+
+ +
+

{t.evening}

-

+

{todaysMeals.evening || t.notPlanned}

- {/* AI Voice Assistant Simulation */} -
-
-
- -
-

Smart Assistant

-
-
- setAiInput(e.target.value)} - placeholder={t.voicePrompt} - className="flex-grow px-4 py-3 rounded-xl text-stone-800 focus:outline-none focus:ring-2 focus:ring-orange-300" - disabled={isProcessing} - /> - -
-

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

- {errorMessage && ( -
- - {errorMessage} -
- )} -
- - {/* Pantry Quick Update */} -
-
+
+
-

{t.pantryCheck}

-

{t.pantryDesc}

-
-
- - setSearchTerm(e.target.value)} - className="pl-10 pr-4 py-2 border border-stone-300 rounded-lg w-full md:w-64 focus:ring-2 focus:ring-orange-500 focus:border-orange-500 outline-none" - /> +

+ {lang === 'hi' ? 'पेंट्री चेक' : 'Pantry'} +

+

{t.pantryCheck}

+
+
+

{t.pantryDesc}

+
+ + setSearchTerm(e.target.value)} + className="w-full rounded-xl border border-stone-300 bg-white py-3 pl-10 pr-4 text-sm outline-none transition focus:border-orange-500 focus:ring-2 focus:ring-orange-100" + /> +
+
-
- {filteredInventory.map((item) => ( -
-
-
- {item.icon} -
-

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

- {lang === 'hi' &&

{item.name}

} +
+ {filteredInventory.map((item) => ( +
+
+
+ {item.icon} +
+

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

+ {lang === 'hi' &&

{item.name}

} +
+ {(item.status === 'low' || item.status === 'out') && ( +
+ + {t.onList} +
+ )}
- {(item.status === 'low' || item.status === 'out') && ( -
- - {t.onList} -
- )} -
- -
- - - -
- {/* Note / Quantity Section */} - {(item.status === 'low' || item.status === 'out') && ( -
- {editingNoteId === item.id ? ( -
- setNoteValue(e.target.value)} - placeholder={lang === 'hi' ? 'कितना चाहिए? (उदा: 2kg)' : 'Quantity? (e.g. 2kg)'} - className="flex-grow px-3 py-2 text-sm border border-stone-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500" - autoFocus - /> +
+ + + +
+ + {(item.status === 'low' || item.status === 'out') && ( +
+ {editingNoteId === item.id ? ( +
+ setNoteValue(e.target.value)} + placeholder={lang === 'hi' ? 'कितना चाहिए? (उदा: 2kg)' : 'Quantity? (e.g. 2kg)'} + 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 + /> + +
+ ) : ( -
- ) : ( - - )} -
- )} -
- ))} + )} +
+ )} +
+ ))} +
- {/* Toast Notification */} {toastMessage && ( -
+
{toastMessage}
)} diff --git a/src/components/GroceryList.tsx b/src/components/GroceryList.tsx index 7e1093d..fe4e1f7 100644 --- a/src/components/GroceryList.tsx +++ b/src/components/GroceryList.tsx @@ -11,40 +11,64 @@ export default function GroceryList({ inventory, onUpdateInventory }: Props) { const lowStockItems = inventory.filter((item) => item.status === 'low' || item.status === 'out'); return ( -
-
- -

Grocery List

+
+
+
+ +
+
+

Owner Grocery List

+

Grocery List

+

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

+
{lowStockItems.length === 0 ? ( -
- -

All caught up!

-

Your pantry is fully stocked.

+
+
+ +
+

All caught up

+

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

) : ( -
+
    {lowStockItems.map((item) => ( -
  • -
    -
    -
    -

    - {item.icon} {item.name} +

  • +
    +
    +
    +
    + {item.icon} + + {item.name} + {(item.requestedQuantity || item.defaultQuantity) && ( - + {item.requestedQuantity || item.defaultQuantity} )} +
    +

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

    -

    {item.category} • {item.status === 'out' ? 'Finished' : 'Running Low'}

    -

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

    - +
    +
    +
    + +
    +

    Weekly Meal Plan

    +

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

    +
    + +
    -
    +
    {weekDays.map((day) => { const dateStr = getLocalDateKey(day); - const isToday = dateStr === getLocalDateKey(new Date()); + const isToday = dateStr === todayDateKey; const dayMeals = meals[dateStr] || { morning: '', evening: '', notes: '', leftovers: '' }; return ( -
    -
    -

    {day.toLocaleDateString('en-US', { weekday: 'short' })}

    -

    {day.getDate()}

    -
    - -
    - {/* Morning */} +
    -
    - - Morning +

    + {day.toLocaleDateString('en-US', { weekday: 'short' })} +

    +

    {day.getDate()}

    +
    + {isToday ? ( + + Today + + ) : null} +
    + +
    +
    +
    + + Breakfast / Lunch