From 1d0bd630b2caa1422a880c52dcfd093c5371f23a Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Tue, 25 Nov 2025 05:59:56 -0800 Subject: [PATCH] test, more id fixes, and naming all files --- .gitea/workflows/deploy.yml | 2 +- .gitea/workflows/manual-db-reset.yml | 2 +- global-setup.ts | 1 + package-lock.json | 11 ++ package.json | 1 + sql/Initial_triggers_and_functions.sql | 1 + sql/drop_tables.sql | 1 + sql/helper_scripts/generate_rollup.ps1 | 2 +- sql/helper_scripts/generate_rollup.sh | 2 +- sql/helper_scripts/verify_rollup.sh | 2 +- sql/initial_data.sql | 1 + sql/initial_schema.sql | 1 + sql/master_schema_rollup.sql | 1 + src/App.tsx | 11 +- src/components/ActivityLog.test.tsx | 140 ++++++++++++++++++ src/components/ActivityLog.tsx | 22 +-- src/components/AdminBrandManager.tsx | 3 +- src/components/AdminPage.tsx | 1 + src/components/AdminRoute.tsx | 1 + src/components/AnalysisPanel.tsx | 2 +- src/components/AnonymousUserBanner.tsx | 1 + src/components/BulkImportSummary.tsx | 5 +- src/components/BulkImporter.tsx | 1 + src/components/ConfirmationModal.tsx | 3 +- src/components/CorrectionRow.tsx | 23 +-- src/components/DarkModeToggle.tsx | 2 +- src/components/ErrorDisplay.tsx | 2 +- src/components/ExtractedDataTable.tsx | 9 +- src/components/FlyerDisplay.tsx | 2 +- src/components/FlyerList.tsx | 1 + src/components/Header.tsx | 1 + src/components/LoadingSpinner.tsx | 2 +- src/components/PasswordInput.tsx | 1 + src/components/PasswordStrengthIndicator.tsx | 1 + src/components/PriceChart.tsx | 1 + src/components/PriceHistoryChart.tsx | 5 +- src/components/ProcessingStatus.tsx | 5 +- src/components/ProfileManager.test.tsx | 5 +- src/components/ProfileManager.tsx | 44 +++--- src/components/SampleDataButton.tsx | 2 +- src/components/ShoppingList.tsx | 19 +-- src/components/SystemCheck.tsx | 1 + src/components/TopDeals.tsx | 1 + src/components/UnitSystemToggle.tsx | 3 +- src/components/VoiceAssistant.tsx | 33 +++-- src/components/WatchedItemsList.tsx | 9 +- src/components/WhatsNewModal.tsx | 1 + src/db/backup_user.ts | 1 + src/index.tsx | 1 + src/pages/AdminPage.test.tsx | 60 ++++++++ src/pages/AdminStatsPage.test.tsx | 93 ++++++++++++ src/pages/AdminStatsPage.tsx | 1 + src/pages/CorrectionsPage.test.tsx | 122 +++++++++++++++ src/routes/admin.ts | 1 + src/services/aiService.server.ts | 27 +++- src/services/db.integration.test.ts | 1 + src/services/db/admin.ts | 5 +- src/services/db/connection.ts | 1 + src/services/db/flyer.ts | 1 + src/services/db/personalization.ts | 1 + src/services/db/recipe.ts | 1 + src/services/db/shopping.ts | 1 + src/services/emailService.server.ts | 1 + src/services/geminiService.ts | 1 + src/services/logger.client.ts | 1 + src/services/logger.server.ts | 1 + src/services/logger.ts | 1 + src/services/notificationService.ts | 1 + .../shopping-list.integration.test.ts | 1 + src/tests/setup/global-setup.ts | 1 + src/tests/setup/integration-global-setup.ts | 1 + src/tests/setup/mock-db.ts | 1 + src/tests/setup/test-db.ts | 1 + src/types.ts | 95 ++++++++++-- src/utils/audioUtils.ts | 1 + src/utils/checksum.ts | 1 + src/utils/pdfConverter.ts | 1 + src/utils/priceParser.ts | 1 + src/utils/processingTimer.ts | 1 + src/utils/timeout.ts | 1 + src/utils/unitConverter.ts | 1 + src/vitest.setup.ts | 3 +- 82 files changed, 708 insertions(+), 116 deletions(-) create mode 100644 src/components/ActivityLog.test.tsx create mode 100644 src/pages/AdminPage.test.tsx create mode 100644 src/pages/AdminStatsPage.test.tsx create mode 100644 src/pages/CorrectionsPage.test.tsx diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index bbed61f..29f3f36 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,4 +1,4 @@ -# FILE: .gitea/workflows/deploy.yml +# .gitea/workflows/deploy.yml # # deploy to production which is an ubuntu co-lo server with nginx + postgres # diff --git a/.gitea/workflows/manual-db-reset.yml b/.gitea/workflows/manual-db-reset.yml index c9ef8f2..5cdacef 100644 --- a/.gitea/workflows/manual-db-reset.yml +++ b/.gitea/workflows/manual-db-reset.yml @@ -1,4 +1,4 @@ -# FILE: .gitea/workflows/manual-db-reset.yml +# .gitea/workflows/manual-db-reset.yml # # DANGER: This workflow is DESTRUCTIVE and intended for manual execution only. # It will completely WIPE and RESET the PRODUCTION database. diff --git a/global-setup.ts b/global-setup.ts index 5049e39..a38bed3 100644 --- a/global-setup.ts +++ b/global-setup.ts @@ -1,3 +1,4 @@ +// global-setup.ts /** * This function is executed once before all tests. * It assumes the database is already running and connection details are diff --git a/package-lock.json b/package-lock.json index b4fc398..a973f24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "@types/passport-local": "^1.0.38", "@types/pg": "^8.15.6", "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", "@types/zxcvbn": "^4.4.5", "@typescript-eslint/eslint-plugin": "^8.47.0", "@typescript-eslint/parser": "^8.47.0", @@ -4439,6 +4440,16 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", diff --git a/package.json b/package.json index 321a282..925bdd3 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@types/passport-local": "^1.0.38", "@types/pg": "^8.15.6", "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", "@types/zxcvbn": "^4.4.5", "@typescript-eslint/eslint-plugin": "^8.47.0", "@typescript-eslint/parser": "^8.47.0", diff --git a/sql/Initial_triggers_and_functions.sql b/sql/Initial_triggers_and_functions.sql index b8d0675..f044f73 100644 --- a/sql/Initial_triggers_and_functions.sql +++ b/sql/Initial_triggers_and_functions.sql @@ -1,3 +1,4 @@ +-- sql/Initial_triggers_and_functions.sql -- This file contains all trigger functions and trigger definitions for the database. -- 1. Set up the trigger to automatically create a profile when a new user signs up. diff --git a/sql/drop_tables.sql b/sql/drop_tables.sql index fa8d1ab..da605a9 100644 --- a/sql/drop_tables.sql +++ b/sql/drop_tables.sql @@ -1,3 +1,4 @@ +-- sql/drop_tables.sql /* -- This script is used to completely reset the public schema by deleting all tables. -- It should be run before re-running the schema.sql.txt script to ensure a clean state. diff --git a/sql/helper_scripts/generate_rollup.ps1 b/sql/helper_scripts/generate_rollup.ps1 index 096fafb..5207539 100644 --- a/sql/helper_scripts/generate_rollup.ps1 +++ b/sql/helper_scripts/generate_rollup.ps1 @@ -1,3 +1,4 @@ +# sql/helper_scripts/generate_rollup.ps1 <# .SYNOPSIS SQL ROLLUP GENERATION SCRIPT (POWERSHELL) @@ -29,4 +30,3 @@ $SourceFiles = @( Write-Host "Generating '$MasterFile' from source files..." Get-Content -Path $SourceFiles | Set-Content -Path $MasterFile -Encoding UTF8 Write-Host "✅ Success: '$MasterFile' has been generated." -ForegroundColor Green - diff --git a/sql/helper_scripts/generate_rollup.sh b/sql/helper_scripts/generate_rollup.sh index 692f13d..c260f47 100644 --- a/sql/helper_scripts/generate_rollup.sh +++ b/sql/helper_scripts/generate_rollup.sh @@ -1,5 +1,5 @@ #!/bin/bash - +# sql/helper_scripts/generate_rollup.sh # ============================================================================ # SQL ROLLUP GENERATION SCRIPT (BASH) # ============================================================================ diff --git a/sql/helper_scripts/verify_rollup.sh b/sql/helper_scripts/verify_rollup.sh index 9bbfe97..fb86c3d 100644 --- a/sql/helper_scripts/verify_rollup.sh +++ b/sql/helper_scripts/verify_rollup.sh @@ -1,5 +1,5 @@ #!/bin/bash - +# sql/helper_scripts/verify_rollup.sh # ============================================================================ # SQL ROLLUP VERIFICATION SCRIPT # ============================================================================ diff --git a/sql/initial_data.sql b/sql/initial_data.sql index 5a3b071..d317071 100644 --- a/sql/initial_data.sql +++ b/sql/initial_data.sql @@ -1,3 +1,4 @@ +-- sql/initial_data.sql -- ============================================================================ -- INITIAL DATA SEEDING SCRIPT -- ============================================================================ diff --git a/sql/initial_schema.sql b/sql/initial_schema.sql index 01cbf5b..42a65d9 100644 --- a/sql/initial_schema.sql +++ b/sql/initial_schema.sql @@ -1,3 +1,4 @@ +-- sql/initial_schema.sql -- ============================================================================ -- ============================================================================ -- PART 2: TABLES diff --git a/sql/master_schema_rollup.sql b/sql/master_schema_rollup.sql index 6886438..b758daa 100644 --- a/sql/master_schema_rollup.sql +++ b/sql/master_schema_rollup.sql @@ -1,3 +1,4 @@ +-- sql/master_schema_rollup.sql -- ============================================================================ -- MASTER SCHEMA SCRIPT -- ============================================================================ diff --git a/src/App.tsx b/src/App.tsx index 58626a5..2129ebe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,4 @@ +// src/App.tsx import React, { useState, useCallback, useEffect } from 'react'; import { Routes, Route } from 'react-router-dom'; import { Toaster } from 'react-hot-toast'; @@ -29,7 +30,7 @@ import { AdminRoute } from './components/AdminRoute'; import { CorrectionsPage } from './pages/CorrectionsPage'; import { ActivityLog, ActivityLogClickHandler } from './components/ActivityLog'; import { WatchedItemsList } from './components/WatchedItemsList'; -import { AdminStatsPage } from './pages/AdminStatPages'; +import { AdminStatsPage } from './pages/AdminStatsPage'; import { ResetPasswordPage } from './pages/ResetPasswordPage'; import { AnonymousUserBanner } from './components/AnonymousUserBanner'; import { VoiceLabPage } from './pages/VoiceLabPage'; // Import the new page @@ -775,10 +776,10 @@ function App() { }; const handleActivityLogClick: ActivityLogClickHandler = (log) => { - if (log.action === 'list_shared' && log.details?.shopping_list_id) { - const listId = parseInt(String(log.details.shopping_list_id), 10); - // Check if the list exists before setting it as active. This was correct. - if (shoppingLists.some(list => list.shopping_list_id === listId) && typeof listId === 'number') { + // Thanks to the discriminated union, if the action is 'list_shared', TypeScript knows 'details.shopping_list_id' is a number. + if (log.action === 'list_shared') { + const listId = log.details.shopping_list_id; + if (shoppingLists.some(list => list.shopping_list_id === listId)) { setActiveListId(listId); } } diff --git a/src/components/ActivityLog.test.tsx b/src/components/ActivityLog.test.tsx new file mode 100644 index 0000000..cf85ee8 --- /dev/null +++ b/src/components/ActivityLog.test.tsx @@ -0,0 +1,140 @@ +// src/components/ActivityLog.test.tsx +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { ActivityLog } from './ActivityLog'; +import * as apiClient from '../services/apiClient'; +import type { ActivityLogItem, User } from '../types'; + +// Mock the apiClient module +vi.mock('../services/apiClient', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchActivityLog: vi.fn(), + }; +}); + +// Mock the logger +vi.mock('../services/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock date-fns to return a consistent value for snapshots +vi.mock('date-fns', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + formatDistanceToNow: vi.fn(() => 'about 5 hours ago'), + }; +}); + +const mockUser: User = { user_id: 'user-123', email: 'test@example.com' }; + +const mockLogs: ActivityLogItem[] = [ + { + activity_log_id: 1, + user_id: 'user-123', + action: 'flyer_processed', + display_text: 'Processed a new flyer for Walmart.', + details: { flyerId: 1, store_name: 'Walmart', user_avatar_url: 'http://example.com/avatar.png', user_full_name: 'Test User' }, + created_at: new Date().toISOString(), + }, + { + activity_log_id: 2, + user_id: 'user-456', + action: 'recipe_created', + display_text: 'Jane Doe added a new recipe: Pasta Carbonara', + details: { recipe_name: 'Pasta Carbonara', user_full_name: 'Jane Doe' }, + created_at: new Date().toISOString(), + }, + { + activity_log_id: 3, + user_id: 'user-789', + action: 'list_shared', + display_text: 'John Smith shared a list.', + details: { list_name: 'Weekly Groceries', shopping_list_id: 10, user_full_name: 'John Smith', shared_with_name: 'Test User' }, + created_at: new Date().toISOString(), + }, +]; + +describe('ActivityLog', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should not render if user is null', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should show a loading state initially', () => { + (apiClient.fetchActivityLog as Mock).mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText('Loading activity...')).toBeInTheDocument(); + }); + + it('should display an error message if fetching logs fails', async () => { + (apiClient.fetchActivityLog as Mock).mockRejectedValue(new Error('API is down')); + render(); + await waitFor(() => { + expect(screen.getByText('API is down')).toBeInTheDocument(); + }); + }); + + it('should display a message when there are no logs', async () => { + (apiClient.fetchActivityLog as Mock).mockResolvedValue([]); + render(); + await waitFor(() => { + expect(screen.getByText('No recent activity to show.')).toBeInTheDocument(); + }); + }); + + it('should render a list of activities successfully', async () => { + (apiClient.fetchActivityLog as Mock).mockResolvedValue(mockLogs); + render(); + await waitFor(() => { + // Check for specific text from different log types + expect(screen.getByText('Walmart')).toBeInTheDocument(); // From flyer_processed + expect(screen.getByText('Pasta Carbonara')).toBeInTheDocument(); // From recipe_created + expect(screen.getByText('Weekly Groceries')).toBeInTheDocument(); // From list_shared + + // Check for user names + expect(screen.getByText('Jane Doe', { exact: false })).toBeInTheDocument(); + + // Check for avatar + const avatar = screen.getByAltText('Test User'); + expect(avatar).toBeInTheDocument(); + expect(avatar).toHaveAttribute('src', 'http://example.com/avatar.png'); + + // Check for the mocked date + expect(screen.getAllByText('about 5 hours ago')).toHaveLength(mockLogs.length); + }); + }); + + it('should call onLogClick when a clickable log item is clicked', async () => { + const onLogClickMock = vi.fn(); + (apiClient.fetchActivityLog as Mock).mockResolvedValue(mockLogs); + render(); + + await waitFor(() => { + // Find the clickable element (the recipe name in this case) + const clickableRecipe = screen.getByText('Pasta Carbonara'); + fireEvent.click(clickableRecipe); + expect(onLogClickMock).toHaveBeenCalledTimes(1); + // The second log item has the recipe + expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[1]); + + const clickableList = screen.getByText('Weekly Groceries'); + fireEvent.click(clickableList); + expect(onLogClickMock).toHaveBeenCalledTimes(2); + // The third log item has the list + expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[2]); + }); + }); +}); \ No newline at end of file diff --git a/src/components/ActivityLog.tsx b/src/components/ActivityLog.tsx index f119bda..375778a 100644 --- a/src/components/ActivityLog.tsx +++ b/src/components/ActivityLog.tsx @@ -1,3 +1,4 @@ +// src/components/ActivityLog.tsx import React, { useState, useEffect } from 'react'; import { fetchActivityLog } from '../services/apiClient'; import { ActivityLogItem } from '../types'; @@ -12,16 +13,17 @@ interface ActivityLogProps { } const renderLogDetails = (log: ActivityLogItem, onLogClick?: ActivityLogClickHandler) => { - const userName = log.details.user_full_name || 'A user'; + // With discriminated unions, we can safely access properties based on the 'action' type. + const userName = 'user_full_name' in log.details ? log.details.user_full_name : 'A user'; const isClickable = onLogClick !== undefined; - switch (log.activity_type) { - case 'new_flyer': + switch (log.action) { + case 'flyer_processed': return ( A new flyer for {log.details.store_name || 'a store'} was added. ); - case 'new_recipe': + case 'recipe_created': return ( {userName} added a new recipe:{" "} @@ -33,13 +35,13 @@ const renderLogDetails = (log: ActivityLogItem, onLogClick?: ActivityLogClickHan . ); - case 'new_user': + case 'user_registered': return ( {log.details.full_name || 'A new user'} just joined! ); - case 'favorite_recipe': + case 'recipe_favorited': return ( {userName} favorited the recipe:{" "} @@ -51,7 +53,7 @@ const renderLogDetails = (log: ActivityLogItem, onLogClick?: ActivityLogClickHan . ); - case 'share_shopping_list': + case 'list_shared': return ( {userName} shared the list " @@ -110,10 +112,10 @@ export const ActivityLog: React.FC = ({ user, onLogClick }) => )}
    {logs.map((log) => ( -
  • +
  • - {log.user_avatar_url ? ( - {log.user_full_name + {log.details?.user_avatar_url ? ( + {log.details.user_full_name ) : ( diff --git a/src/components/AdminBrandManager.tsx b/src/components/AdminBrandManager.tsx index 7a7d634..2d96b50 100644 --- a/src/components/AdminBrandManager.tsx +++ b/src/components/AdminBrandManager.tsx @@ -1,3 +1,4 @@ +// src/components/AdminBrandManager.tsx import React, { useState, useEffect } from 'react'; import toast from 'react-hot-toast'; import { fetchAllBrands, uploadBrandLogo } from '../services/apiClient'; @@ -97,7 +98,7 @@ export const AdminBrandManager: React.FC = () => {
    {hasContent ? ( -
    +
    {summary.processed.length > 0 && (

    @@ -74,7 +75,7 @@ export const BulkImportSummary: React.FC = ({ summary, o )}

    ) : ( -
    +

    No new files were found to process.

    diff --git a/src/components/BulkImporter.tsx b/src/components/BulkImporter.tsx index 94293e9..2635004 100644 --- a/src/components/BulkImporter.tsx +++ b/src/components/BulkImporter.tsx @@ -1,3 +1,4 @@ +// src/components/BulkImporter.tsx import React, { useCallback, useState } from 'react'; import { UploadIcon } from './icons/UploadIcon'; diff --git a/src/components/ConfirmationModal.tsx b/src/components/ConfirmationModal.tsx index b67ce2a..cd87196 100644 --- a/src/components/ConfirmationModal.tsx +++ b/src/components/ConfirmationModal.tsx @@ -1,3 +1,4 @@ +// src/components/ConfirmationModal.tsx import React from 'react'; import { XMarkIcon } from './icons/XMarkIcon'; import { ExclamationTriangleIcon } from './icons/ExclamationTriangleIcon'; @@ -45,7 +46,7 @@ export const ConfirmationModal: React.FC = ({
    -
    +
    diff --git a/src/components/CorrectionRow.tsx b/src/components/CorrectionRow.tsx index 6b36d44..5782704 100644 --- a/src/components/CorrectionRow.tsx +++ b/src/components/CorrectionRow.tsx @@ -1,3 +1,4 @@ +// src/components/CorrectionRow.tsx import React, { useState } from 'react'; import type { SuggestedCorrection, MasterGroceryItem, Category } from '../types'; import { approveCorrection, rejectCorrection, updateSuggestedCorrection } from '../services/apiClient'; // Ensure we are using apiClient @@ -35,7 +36,7 @@ export const CorrectionRow: React.FC = ({ correction: initia } if (correction_type === 'INCORRECT_ITEM_LINK') { const masterItemId = parseInt(suggested_value, 10); - const item = masterItems.find(mi => mi.master_item_id === masterItemId); + const item = masterItems.find(mi => mi.master_grocery_item_id === masterItemId); return item ? `${item.name} (ID: ${masterItemId})` : `Unknown Item (ID: ${masterItemId})`; } if (correction_type === 'ITEM_IS_MISCATEGORIZED') { @@ -54,18 +55,18 @@ export const CorrectionRow: React.FC = ({ correction: initia setError(null); try { if (actionToConfirm === 'approve') { - await approveCorrection(currentCorrection.correction_id); - logger.info(`Correction ${currentCorrection.correction_id} approved.`); + await approveCorrection(currentCorrection.suggested_correction_id); + logger.info(`Correction ${currentCorrection.suggested_correction_id} approved.`); } else if (actionToConfirm === 'reject') { - await rejectCorrection(currentCorrection.correction_id); - logger.info(`Correction ${currentCorrection.correction_id} rejected.`); + await rejectCorrection(currentCorrection.suggested_correction_id); + logger.info(`Correction ${currentCorrection.suggested_correction_id} rejected.`); } - onProcessed(initialCorrection.correction_id); + onProcessed(initialCorrection.suggested_correction_id); } catch (err) { // This is a type-safe way to handle errors. We check if the caught // object is an instance of Error before accessing its message property. const errorMessage = err instanceof Error ? err.message : `An unknown error occurred while trying to ${actionToConfirm} the correction.`; - logger.error(`Failed to ${actionToConfirm} correction ${currentCorrection.correction_id}`, { error: errorMessage }); + logger.error(`Failed to ${actionToConfirm} correction ${currentCorrection.suggested_correction_id}`, { error: errorMessage }); setError(errorMessage); // Show error on the row setIsProcessing(false); } @@ -76,12 +77,12 @@ export const CorrectionRow: React.FC = ({ correction: initia setIsProcessing(true); setError(null); try { - const updatedCorrection = await updateSuggestedCorrection(currentCorrection.correction_id, editableValue); + const updatedCorrection = await updateSuggestedCorrection(currentCorrection.suggested_correction_id, editableValue); setCurrentCorrection(updatedCorrection); // Update local state with the saved version setIsEditing(false); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to save changes.'; - logger.error(`Failed to update correction ${currentCorrection.correction_id}`, { error: errorMessage }); + logger.error(`Failed to update correction ${currentCorrection.suggested_correction_id}`, { error: errorMessage }); setError(errorMessage); } finally { setIsProcessing(false); @@ -107,7 +108,7 @@ export const CorrectionRow: React.FC = ({ correction: initia onChange={(e) => setEditableValue(e.target.value)} className="form-select w-full text-sm dark:bg-gray-700 dark:border-gray-600" > - {masterItems.map(item => )} + {masterItems.map(item => )} ); case 'ITEM_IS_MISCATEGORIZED': @@ -117,7 +118,7 @@ export const CorrectionRow: React.FC = ({ correction: initia onChange={(e) => setEditableValue(e.target.value)} className="form-select w-full text-sm dark:bg-gray-700 dark:border-gray-600" > - {categories.map(cat => )} + {categories.map(cat => )} ); default: diff --git a/src/components/DarkModeToggle.tsx b/src/components/DarkModeToggle.tsx index f741b9b..6b56440 100644 --- a/src/components/DarkModeToggle.tsx +++ b/src/components/DarkModeToggle.tsx @@ -1,4 +1,4 @@ - +// src/components/DarkModeToggle.tsx import React from 'react'; import { SunIcon } from './icons/SunIcon'; import { MoonIcon } from './icons/MoonIcon'; diff --git a/src/components/ErrorDisplay.tsx b/src/components/ErrorDisplay.tsx index e831839..7def008 100644 --- a/src/components/ErrorDisplay.tsx +++ b/src/components/ErrorDisplay.tsx @@ -1,4 +1,4 @@ - +// src/components/ErrorDisplay.tsx import React from 'react'; interface ErrorDisplayProps { diff --git a/src/components/ExtractedDataTable.tsx b/src/components/ExtractedDataTable.tsx index 72df8c7..602f142 100644 --- a/src/components/ExtractedDataTable.tsx +++ b/src/components/ExtractedDataTable.tsx @@ -1,3 +1,4 @@ +// src/components/ExtractedDataTable.tsx import React, { useMemo, useState } from 'react'; import type { FlyerItem, MasterGroceryItem, ShoppingList } from '../types'; import { formatUnitPrice } from '../utils/unitConverter'; @@ -20,12 +21,12 @@ interface ExtractedDataTableProps { export const ExtractedDataTable: React.FC = ({ items, totalActiveItems, watchedItems = [], masterItems, unitSystem, user, onAddItem, shoppingLists, activeListId, onAddItemToList }) => { const [categoryFilter, setCategoryFilter] = useState('all'); - const watchedItemIds = useMemo(() => new Set(watchedItems.map(item => item.item_id)), [watchedItems]); - const masterItemsMap = useMemo(() => new Map(masterItems.map(item => [item.item_id, item.name])), [masterItems]); + const watchedItemIds = useMemo(() => new Set(watchedItems.map(item => item.master_grocery_item_id)), [watchedItems]); + const masterItemsMap = useMemo(() => new Map(masterItems.map(item => [item.master_grocery_item_id, item.name])), [masterItems]); const activeShoppingListItems = useMemo(() => { if (!activeListId) return new Set(); - const activeList = shoppingLists.find(list => list.list_id === activeListId); + const activeList = shoppingLists.find(list => list.shopping_list_id === activeListId); return new Set(activeList?.items.map(item => item.master_item_id)); }, [shoppingLists, activeListId]); @@ -115,7 +116,7 @@ export const ExtractedDataTable: React.FC = ({ items, t const formattedUnitPrice = formatUnitPrice(item.unit_price, unitSystem); return ( - +
    {item.item}
    diff --git a/src/components/FlyerDisplay.tsx b/src/components/FlyerDisplay.tsx index aa5e209..0da9aed 100644 --- a/src/components/FlyerDisplay.tsx +++ b/src/components/FlyerDisplay.tsx @@ -1,4 +1,4 @@ - +// src/components/FlyerDisplay.tsx import React from 'react'; import type { Store } from '../types'; diff --git a/src/components/FlyerList.tsx b/src/components/FlyerList.tsx index d2ec72f..bd517d3 100644 --- a/src/components/FlyerList.tsx +++ b/src/components/FlyerList.tsx @@ -1,3 +1,4 @@ +// src/components/FlyerList.tsx import React from 'react'; import type { Flyer } from '../types'; import { DocumentTextIcon } from './icons/DocumentTextIcon'; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 7386e26..85ef4b7 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,3 +1,4 @@ +// src/components/Header.tsx import React from 'react'; import { ShoppingCartIcon } from './icons/ShoppingCartIcon'; import { UserIcon } from './icons/UserIcon'; diff --git a/src/components/LoadingSpinner.tsx b/src/components/LoadingSpinner.tsx index 9fcc422..2908ab9 100644 --- a/src/components/LoadingSpinner.tsx +++ b/src/components/LoadingSpinner.tsx @@ -1,4 +1,4 @@ - +// src/components/LoadingSpinner.tsx import React from 'react'; export const LoadingSpinner: React.FC = () => ( diff --git a/src/components/PasswordInput.tsx b/src/components/PasswordInput.tsx index 50f26c2..2a31774 100644 --- a/src/components/PasswordInput.tsx +++ b/src/components/PasswordInput.tsx @@ -1,3 +1,4 @@ +// src/components/PasswordInput.tsx import React, { useState } from 'react'; import { EyeIcon } from './icons/EyeIcon'; import { EyeSlashIcon } from './icons/EyeSlashIcon'; diff --git a/src/components/PasswordStrengthIndicator.tsx b/src/components/PasswordStrengthIndicator.tsx index 8c47cfe..0a453be 100644 --- a/src/components/PasswordStrengthIndicator.tsx +++ b/src/components/PasswordStrengthIndicator.tsx @@ -1,3 +1,4 @@ +// src/components/PasswordStrengthIndicator.tsx import React from 'react'; import zxcvbn from 'zxcvbn'; diff --git a/src/components/PriceChart.tsx b/src/components/PriceChart.tsx index 2578ce4..b99ce5f 100644 --- a/src/components/PriceChart.tsx +++ b/src/components/PriceChart.tsx @@ -1,3 +1,4 @@ +// src/components/PriceChart.tsx import React from 'react'; import type { DealItem, User } from '../types'; import { TagIcon } from './icons/TagIcon'; diff --git a/src/components/PriceHistoryChart.tsx b/src/components/PriceHistoryChart.tsx index bd2a888..50d8645 100644 --- a/src/components/PriceHistoryChart.tsx +++ b/src/components/PriceHistoryChart.tsx @@ -1,3 +1,4 @@ +// src/components/PriceHistoryChart.tsx import React, { useState, useEffect, useMemo } from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { fetchHistoricalPriceData } from '../services/apiClient'; @@ -18,7 +19,7 @@ export const PriceHistoryChart: React.FC = ({ watchedIte const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const watchedItemsMap = useMemo(() => new Map(watchedItems.map(item => [item.item_id, item.name])), [watchedItems]); + const watchedItemsMap = useMemo(() => new Map(watchedItems.map(item => [item.master_grocery_item_id, item.name])), [watchedItems]); useEffect(() => { if (watchedItems.length === 0) { @@ -31,7 +32,7 @@ export const PriceHistoryChart: React.FC = ({ watchedIte setIsLoading(true); setError(null); try { - const watchedItemIds = watchedItems.map(item => item.item_id); + const watchedItemIds = watchedItems.map(item => item.master_grocery_item_id); const rawData = await fetchHistoricalPriceData(watchedItemIds); if (rawData.length === 0) { setHistoricalData({}); diff --git a/src/components/ProcessingStatus.tsx b/src/components/ProcessingStatus.tsx index eee30b9..1441eb6 100644 --- a/src/components/ProcessingStatus.tsx +++ b/src/components/ProcessingStatus.tsx @@ -1,3 +1,4 @@ +// src/components/ProcessingStatus.tsx import React, { useState, useEffect } from 'react'; import { LoadingSpinner } from './LoadingSpinner'; import { CheckCircleIcon } from './icons/CheckCircleIcon'; @@ -77,7 +78,7 @@ export const ProcessingStatus: React.FC = ({ stages, esti return (
  • -
    +
    @@ -194,7 +195,7 @@ export const ProcessingStatus: React.FC = ({ stages, esti return (
  • -
    +
    diff --git a/src/components/ProfileManager.test.tsx b/src/components/ProfileManager.test.tsx index 73f4e47..ba30882 100644 --- a/src/components/ProfileManager.test.tsx +++ b/src/components/ProfileManager.test.tsx @@ -1,3 +1,4 @@ +// src/components/ProfileManager.test.tsx import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; @@ -308,9 +309,9 @@ describe('ProfileManager Authentication Flows', () => { render( ); diff --git a/src/components/ProfileManager.tsx b/src/components/ProfileManager.tsx index bff5796..c79b4f2 100644 --- a/src/components/ProfileManager.tsx +++ b/src/components/ProfileManager.tsx @@ -1,3 +1,4 @@ +// src/components/ProfileManager.tsx import React, { useState, useEffect } from 'react'; import type { Profile } from '../types'; import { updateUserPreferences, updateUserPassword, deleteUserAccount, loginUser, registerUser, requestPasswordReset, updateUserProfile, exportUserData } from '../services/apiClient'; @@ -76,10 +77,12 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, const handleProfileSave = async (e: React.FormEvent) => { e.preventDefault(); setProfileLoading(true); + if (!user) { + notifyError("Cannot save profile, no user is logged in."); + setProfileLoading(false); + return; + } try { - if (!user) { - throw new Error("Cannot save profile, no user is logged in."); - } const updatedProfile = await updateUserProfile({ // Use the new apiClient function full_name: fullName, avatar_url: avatarUrl @@ -119,10 +122,12 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, return; } setPasswordLoading(true); + if (!user) { + notifyError("Cannot update password, no user is logged in."); + setPasswordLoading(false); + return; + } try { - if (!user) { - throw new Error("Cannot update password, no user is logged in."); - } await updateUserPassword(password); // This now uses the new apiClient function logger.info('User password updated successfully.', { userId: user.user_id }); notifySuccess("Password updated successfully!"); @@ -139,10 +144,12 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, const handleExportData = async () => { setExportLoading(true); + if (!user) { + notifyError("Cannot export data, no user is logged in."); + setExportLoading(false); + return; + } try { - if (!user) { - throw new Error("Cannot export data, no user is logged in."); - } logger.info('User initiated data export.', { userId: user.user_id }); const userData = await exportUserData(); // Call the new apiClient function const jsonString = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(userData, null, 2))}`; @@ -159,8 +166,7 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, } }; - const handleDeleteAccount = async (e: React.FormEvent) => { - e.preventDefault(); + const handleDeleteAccount = async () => { setIsDeleteModalOpen(false); // Close the confirmation modal setDeleteLoading(true); @@ -192,10 +198,11 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, }; const handleToggleDarkMode = async (newMode: boolean) => { + if (!user) { + notifyError("Cannot update preferences, no user is logged in."); + return; + } try { - if (!user) { - throw new Error("Cannot update preferences, no user is logged in."); - } // Call the API client function to update preferences const updatedProfile = await updateUserPreferences({ darkMode: newMode }); // Notify parent component (App.tsx) to update its profile state @@ -209,10 +216,11 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, }; const handleToggleUnitSystem = async (newSystem: 'metric' | 'imperial') => { + if (!user) { + notifyError("Cannot update preferences, no user is logged in."); + return; + } try { - if (!user) { - throw new Error("Cannot update preferences, no user is logged in."); - } // Call the API client function to update preferences const updatedProfile = await updateUserPreferences({ unitSystem: newSystem }); // Notify parent component (App.tsx) to update its profile state @@ -289,7 +297,7 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, setIsDeleteModalOpen(false)} - onConfirm={handleDeleteAccount} + onConfirm={handleDeleteAccount} // Pass the handler directly title="Confirm Account Deletion" message={ <> diff --git a/src/components/SampleDataButton.tsx b/src/components/SampleDataButton.tsx index 5963cfb..6658b20 100644 --- a/src/components/SampleDataButton.tsx +++ b/src/components/SampleDataButton.tsx @@ -1,4 +1,4 @@ - +// src/components/SampleDataButton.tsx import React from 'react'; interface SampleDataButtonProps { diff --git a/src/components/ShoppingList.tsx b/src/components/ShoppingList.tsx index 7475526..a02faf1 100644 --- a/src/components/ShoppingList.tsx +++ b/src/components/ShoppingList.tsx @@ -1,3 +1,4 @@ +// src/components/ShoppingList.tsx import React, { useState, useMemo, useCallback } from 'react'; import type { ShoppingList, ShoppingListItem, User } from '../types'; import { UserIcon } from './icons/UserIcon'; @@ -27,7 +28,7 @@ export const ShoppingListComponent: React.FC = ({ us const [isAddingCustom, setIsAddingCustom] = useState(false); const [isReadingAloud, setIsReadingAloud] = useState(false); - const activeList = useMemo(() => lists.find(list => list.list_id === activeListId), [lists, activeListId]); + const activeList = useMemo(() => lists.find(list => list.shopping_list_id === activeListId), [lists, activeListId]); const { neededItems, purchasedItems } = useMemo(() => { if (!activeList) return { neededItems: [], purchasedItems: [] }; const neededItems: ShoppingListItem[] = []; @@ -53,7 +54,7 @@ export const ShoppingListComponent: React.FC = ({ us const handleDeleteList = async () => { if (activeList && window.confirm(`Are you sure you want to delete the "${activeList.name}" list? This cannot be undone.`)) { - await onDeleteList(activeList.active_list_id); + await onDeleteList(activeList.shopping_list_id); } }; @@ -130,7 +131,7 @@ export const ShoppingListComponent: React.FC = ({ us onChange={(e) => onSelectList(Number(e.target.value))} className="block w-full pl-3 pr-8 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary" > - {lists.map(list => )} + {lists.map(list => )} )}
    @@ -161,15 +162,15 @@ export const ShoppingListComponent: React.FC = ({ us
    {neededItems.length > 0 ? neededItems.map(item => ( -
    +
    onUpdateItem(item.item_id, { is_purchased: !item.is_purchased })} + onChange={() => onUpdateItem(item.shopping_list_item_id, { is_purchased: !item.is_purchased })} className="h-4 w-4 rounded border-gray-300 text-brand-primary focus:ring-brand-secondary" /> {item.custom_item_name || item.master_item?.name} -
    @@ -181,15 +182,15 @@ export const ShoppingListComponent: React.FC = ({ us

    Purchased

    {purchasedItems.map(item => ( -
    +
    onUpdateItem(item.item_id, { is_purchased: !item.is_purchased })} + onChange={() => onUpdateItem(item.shopping_list_item_id, { is_purchased: !item.is_purchased })} className="h-4 w-4 rounded border-gray-300 text-brand-primary focus:ring-brand-secondary" /> {item.custom_item_name || item.master_item?.name} -
    diff --git a/src/components/SystemCheck.tsx b/src/components/SystemCheck.tsx index cd311dd..0e23352 100644 --- a/src/components/SystemCheck.tsx +++ b/src/components/SystemCheck.tsx @@ -1,3 +1,4 @@ +// src/components/SystemCheck.tsx import React, { useState, useEffect, useCallback } from 'react'; import { loginUser, pingBackend, checkDbSchema, checkStorage, checkDbPoolHealth, checkPm2Status } from '../services/apiClient'; import { ShieldCheckIcon } from './icons/ShieldCheckIcon'; diff --git a/src/components/TopDeals.tsx b/src/components/TopDeals.tsx index 492b386..3dd7bfb 100644 --- a/src/components/TopDeals.tsx +++ b/src/components/TopDeals.tsx @@ -1,3 +1,4 @@ +// src/components/TopDeals.tsx import React, { useMemo } from 'react'; import type { FlyerItem } from '../types'; import { TrophyIcon } from './icons/TrophyIcon'; diff --git a/src/components/UnitSystemToggle.tsx b/src/components/UnitSystemToggle.tsx index 508baac..4a75128 100644 --- a/src/components/UnitSystemToggle.tsx +++ b/src/components/UnitSystemToggle.tsx @@ -1,3 +1,4 @@ +// src/components/UnitSystemToggle.tsx import React from 'react'; interface UnitSystemToggleProps { @@ -21,7 +22,7 @@ export const UnitSystemToggle: React.FC = ({ currentSyste checked={isImperial} onChange={onToggle} /> -
    +
    Imperial diff --git a/src/components/VoiceAssistant.tsx b/src/components/VoiceAssistant.tsx index 0646e82..e6ea3c1 100644 --- a/src/components/VoiceAssistant.tsx +++ b/src/components/VoiceAssistant.tsx @@ -1,3 +1,4 @@ +// src/components/VoiceAssistant.tsx import React, { useState, useEffect, useCallback, useRef } from 'react'; import { startVoiceSession } from '../services/aiApiClient'; import { MicrophoneIcon } from './icons/MicrophoneIcon'; @@ -14,15 +15,20 @@ interface VoiceAssistantProps { type VoiceStatus = 'idle' | 'connecting' | 'listening' | 'speaking' | 'error'; +// Define a local interface for the session object to provide type safety. +interface LiveSession { + close: () => void; + sendRealtimeInput: (input: { media: Blob }) => void; +} + export const VoiceAssistant: React.FC = ({ isOpen, onClose }) => { const [status, setStatus] = useState('idle'); const [userTranscript, setUserTranscript] = useState(''); const [modelTranscript, setModelTranscript] = useState(''); const [history, setHistory] = useState<{speaker: 'user' | 'model', text: string}[]>([]); - // FIX: Infer the session promise type from the return type of `startVoiceSession` - // to avoid needing to import the `LiveSession` type directly. - const sessionPromiseRef = useRef | null>(null); // This correctly infers the Promise type + // Use the local LiveSession interface for the ref type. + const sessionPromiseRef = useRef | null>(null); const mediaStreamRef = useRef(null); const audioContextRef = useRef(null); const scriptProcessorRef = useRef(null); @@ -44,7 +50,7 @@ export const VoiceAssistant: React.FC = ({ isOpen, onClose const handleClose = useCallback(() => { if (sessionPromiseRef.current) { - sessionPromiseRef.current.then(session => session.close()); + sessionPromiseRef.current.then((session: LiveSession) => session.close()); sessionPromiseRef.current = null; } stopRecording(); @@ -84,7 +90,7 @@ export const VoiceAssistant: React.FC = ({ isOpen, onClose data: encode(new Uint8Array(new Int16Array(inputData.map(x => x * 32768)).buffer)), mimeType: 'audio/pcm;rate=16000', }; - sessionPromiseRef.current?.then((session) => { + sessionPromiseRef.current?.then((session: LiveSession) => { session.sendRealtimeInput({ media: pcmBlob }); }); }; @@ -94,14 +100,19 @@ export const VoiceAssistant: React.FC = ({ isOpen, onClose onmessage: (message: LiveServerMessage) => { // NOTE: This stub doesn't play audio, just displays transcripts. // A full implementation would use the audioUtils to decode and play audio. - - if (message.serverContent?.inputTranscription) { - setUserTranscript(prev => prev + message.serverContent.inputTranscription.text); + const serverContent = message.serverContent; + if (!serverContent) { + return; // Exit if there's no content to process } - if (message.serverContent?.outputTranscription) { - setModelTranscript(prev => prev + message.serverContent.outputTranscription.text); + + // Safely access nested properties with optional chaining. + if (serverContent.inputTranscription?.text) { + setUserTranscript(prev => prev + serverContent.inputTranscription!.text); } - if (message.serverContent?.turnComplete) { + if (serverContent.outputTranscription?.text) { + setModelTranscript(prev => prev + serverContent.outputTranscription!.text); + } + if (serverContent.turnComplete) { setHistory(prev => [...prev, { speaker: 'user', text: userTranscript }, { speaker: 'model', text: modelTranscript } diff --git a/src/components/WatchedItemsList.tsx b/src/components/WatchedItemsList.tsx index 588655e..47c0c1c 100644 --- a/src/components/WatchedItemsList.tsx +++ b/src/components/WatchedItemsList.tsx @@ -1,3 +1,4 @@ +// src/components/WatchedItemsList.tsx import React, { useState, useMemo } from 'react'; import type { MasterGroceryItem, User } from '../types'; import { EyeIcon } from './icons/EyeIcon'; @@ -36,7 +37,7 @@ export const WatchedItemsList: React.FC = ({ items, onAdd setNewCategory(''); } catch (error) { // Error is handled in the parent component - logger.error(error); + logger.error('Failed to add watched item from WatchedItemsList', { error }); } finally { setIsAdding(false); } @@ -147,14 +148,14 @@ export const WatchedItemsList: React.FC = ({ items, onAdd {sortedAndFilteredItems.length > 0 ? (
      {sortedAndFilteredItems.map(item => ( -
    • +
    • {item.name} {item.category_name}