rename grocery to product

This commit is contained in:
lasse 2025-05-26 20:20:21 +02:00
parent 1b984d18d9
commit d27871160e
26 changed files with 1114 additions and 498 deletions

View File

@ -1,6 +1,6 @@
# 🚀 Quick Start Guide # 🚀 Quick Start Guide
Get your Grocery Tracker up and running in minutes! Get your Product Tracker up and running in minutes!
## Prerequisites ## Prerequisites
@ -62,8 +62,8 @@ This will:
## First Steps ## First Steps
1. **Add a Shop**: Go to "Shops" and add your first grocery store 1. **Add a Shop**: Go to "Shops" and add your first store
2. **Add Groceries**: Go to "Groceries" and add some items 2. **Add Products**: Go to "Products" and add some items
3. **Record a Purchase**: Use "Add Purchase" to record your shopping 3. **Record a Purchase**: Use "Add Purchase" to record your shopping
## Troubleshooting ## Troubleshooting
@ -85,8 +85,8 @@ This will:
**Complete Backend**: FastAPI with SQLAlchemy and SQLite **Complete Backend**: FastAPI with SQLAlchemy and SQLite
**Modern Frontend**: React with TypeScript and Tailwind CSS **Modern Frontend**: React with TypeScript and Tailwind CSS
**Database Models**: Groceries, Shops, Shopping Events **Database Models**: Products, Shops, Shopping Events
**API Documentation**: Automatic Swagger docs **API Documentation**: Automatic Swagger docs
**Beautiful UI**: Responsive design with modern components **Beautiful UI**: Responsive design with modern components
Happy grocery tracking! 🛒 Happy product tracking! 🛒

View File

@ -1,12 +1,12 @@
# Grocery Tracker # Product Tracker
A web application for tracking grocery prices and shopping events. Built with FastAPI (Python) backend and React (TypeScript) frontend. A web application for tracking product prices and shopping events. Built with FastAPI (Python) backend and React (TypeScript) frontend.
## Features ## Features
- **Grocery Management**: Add, edit, and track grocery items with prices, categories, and organic status - **Product Management**: Add, edit, and track product items with prices, categories, and organic status
- **Shop Management**: Manage different shops with locations - **Shop Management**: Manage different shops with locations
- **Shopping Events**: Record purchases with multiple groceries and amounts - **Shopping Events**: Record purchases with multiple products and amounts
- **Price Tracking**: Monitor price changes over time - **Price Tracking**: Monitor price changes over time
- **Modern UI**: Clean, responsive interface built with React and Tailwind CSS - **Modern UI**: Clean, responsive interface built with React and Tailwind CSS
@ -42,9 +42,9 @@ A web application for tracking grocery prices and shopping events. Built with Fa
### Core Entities ### Core Entities
#### Groceries (`groceries` table) #### Products (`products` table)
- `id`: Integer, Primary key, Auto-increment - `id`: Integer, Primary key, Auto-increment
- `name`: String, Grocery name (indexed, required) - `name`: String, Product name (indexed, required)
- `category`: String, Food category (required) - `category`: String, Food category (required)
- `organic`: Boolean, Organic flag (default: false) - `organic`: Boolean, Organic flag (default: false)
- `weight`: Float, Weight/volume (optional) - `weight`: Float, Weight/volume (optional)
@ -72,11 +72,11 @@ A web application for tracking grocery prices and shopping events. Built with Fa
### Association Table ### Association Table
#### Shopping Event Groceries (`shopping_event_groceries` table) #### Shopping Event Products (`shopping_event_products` table)
Many-to-many relationship between shopping events and groceries with additional data: Many-to-many relationship between shopping events and products with additional data:
- `id`: Integer, Primary key, Auto-increment - `id`: Integer, Primary key, Auto-increment
- `shopping_event_id`: Integer, Foreign key to shopping_events (required) - `shopping_event_id`: Integer, Foreign key to shopping_events (required)
- `grocery_id`: Integer, Foreign key to groceries (required) - `product_id`: Integer, Foreign key to products (required)
- `amount`: Float, Quantity purchased in this event (required, > 0) - `amount`: Float, Quantity purchased in this event (required, > 0)
- `price`: Float, Price at time of purchase (required, ≥ 0) - `price`: Float, Price at time of purchase (required, ≥ 0)
@ -84,10 +84,10 @@ Many-to-many relationship between shopping events and groceries with additional
``` ```
┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐
│ Shops │ │ Shopping Event Groceries │ │ Groceries │ │ Shops │ │ Shopping Event Products │ │ Products │
│ │ │ (Association Table) │ │ │ │ │ │ (Association Table) │ │ │
│ • id │ ←──────→│ • shopping_event_id │ ←──────→│ • id │ │ • id │ ←──────→│ • shopping_event_id │ ←──────→│ • id │
│ • name │ 1:N │ • grocery_id │ N:M │ • name │ │ • name │ 1:N │ • product_id │ N:M │ • name │
│ • city │ │ • amount │ │ • category │ │ • city │ │ • amount │ │ • category │
│ • address │ │ • price │ │ • organic │ │ • address │ │ • price │ │ • organic │
│ • created_at │ │ │ │ • weight │ │ • created_at │ │ │ │ • weight │
@ -110,7 +110,7 @@ Many-to-many relationship between shopping events and groceries with additional
### Key Features ### Key Features
- **Price History**: Each grocery purchase stores the price at that time, enabling price tracking - **Price History**: Each product purchase stores the price at that time, enabling price tracking
- **Flexible Quantities**: Support for decimal amounts (e.g., 1.5 kg of apples) - **Flexible Quantities**: Support for decimal amounts (e.g., 1.5 kg of apples)
- **Auto-calculation**: Total amount can be automatically calculated from individual items - **Auto-calculation**: Total amount can be automatically calculated from individual items
- **Free Items**: Supports items with price 0 (samples, promotions, etc.) - **Free Items**: Supports items with price 0 (samples, promotions, etc.)
@ -146,7 +146,7 @@ Many-to-many relationship between shopping events and groceries with additional
4. **Setup database:** 4. **Setup database:**
```bash ```bash
# Create PostgreSQL database # Create PostgreSQL database
createdb grocery_tracker createdb product_tracker
# Copy environment variables # Copy environment variables
cp env.example .env cp env.example .env
@ -189,12 +189,12 @@ Many-to-many relationship between shopping events and groceries with additional
## API Endpoints ## API Endpoints
### Groceries ### Products
- `GET /groceries/` - List all groceries - `GET /products/` - List all products
- `POST /groceries/` - Create new grocery - `POST /products/` - Create new product
- `GET /groceries/{id}` - Get specific grocery - `GET /products/{id}` - Get specific product
- `PUT /groceries/{id}` - Update grocery - `PUT /products/{id}` - Update product
- `DELETE /groceries/{id}` - Delete grocery - `DELETE /products/{id}` - Delete product
### Shops ### Shops
- `GET /shops/` - List all shops - `GET /shops/` - List all shops
@ -212,8 +212,8 @@ Many-to-many relationship between shopping events and groceries with additional
## Usage ## Usage
1. **Add Shops**: Start by adding shops where you buy groceries 1. **Add Shops**: Start by adding shops where you buy products
2. **Add Groceries**: Create grocery items with prices and categories 2. **Add Products**: Create product items with prices and categories
3. **Record Purchases**: Use the "Add Purchase" form to record shopping events 3. **Record Purchases**: Use the "Add Purchase" form to record shopping events
4. **Track Prices**: Monitor how prices change over time 4. **Track Prices**: Monitor how prices change over time
5. **View Statistics**: Analyze spending patterns by category and shop 5. **View Statistics**: Analyze spending patterns by category and shop
@ -252,8 +252,8 @@ services:
db: db:
image: postgres:15 image: postgres:15
environment: environment:
POSTGRES_DB: grocery_tracker POSTGRES_DB: product_tracker
POSTGRES_USER: grocery_user POSTGRES_USER: product_user
POSTGRES_PASSWORD: your_password POSTGRES_PASSWORD: your_password
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
@ -267,7 +267,7 @@ services:
depends_on: depends_on:
- db - db
environment: environment:
DATABASE_URL: postgresql://grocery_user:your_password@db:5432/grocery_tracker DATABASE_URL: postgresql://product_user:your_password@db:5432/product_tracker
frontend: frontend:
build: ./frontend build: ./frontend

View File

@ -1,12 +1,12 @@
# Test Data Scripts Documentation # Test Data Scripts Documentation
This directory contains scripts for creating and managing test data for the Grocery Tracker application. This directory contains scripts for creating and managing test data for the Product Tracker application.
## Scripts Overview ## Scripts Overview
### 1. `create_test_data.py` - Comprehensive Test Data Generator ### 1. `create_test_data.py` - Comprehensive Test Data Generator
Creates realistic test data including shops, groceries, and shopping events. Creates realistic test data including shops, products, and shopping events.
#### Basic Usage #### Basic Usage
@ -32,7 +32,7 @@ python create_test_data.py --dry-run
| `--days N` | Number of days back to generate events | 90 | | `--days N` | Number of days back to generate events | 90 |
| `--url URL` | API base URL | http://localhost:8000 | | `--url URL` | API base URL | http://localhost:8000 |
| `--shops-only` | Create only shops | False | | `--shops-only` | Create only shops | False |
| `--groceries-only` | Create only groceries | False | | `--products-only` | Create only products | False |
| `--events-only` | Create only shopping events (requires existing data) | False | | `--events-only` | Create only shopping events (requires existing data) | False |
| `--verbose`, `-v` | Verbose output with detailed progress | False | | `--verbose`, `-v` | Verbose output with detailed progress | False |
| `--dry-run` | Show what would be created without creating it | False | | `--dry-run` | Show what would be created without creating it | False |
@ -43,10 +43,10 @@ python create_test_data.py --dry-run
# Create only shops # Create only shops
python create_test_data.py --shops-only python create_test_data.py --shops-only
# Create only groceries # Create only products
python create_test_data.py --groceries-only python create_test_data.py --products-only
# Create 100 shopping events using existing shops and groceries # Create 100 shopping events using existing shops and products
python create_test_data.py --events-only --events 100 python create_test_data.py --events-only --events 100
# Create test data for the past 6 months with verbose output # Create test data for the past 6 months with verbose output
@ -74,14 +74,14 @@ python cleanup_test_data.py
- **Safeway** (San Francisco) - **Safeway** (San Francisco)
- **Trader Joe's** (Berkeley) - **Trader Joe's** (Berkeley)
- **Berkeley Bowl** (Berkeley) - **Berkeley Bowl** (Berkeley)
- **Rainbow Grocery** (San Francisco) - **Rainbow Product** (San Francisco)
- **Mollie Stone's Market** (Palo Alto) - **Mollie Stone's Market** (Palo Alto)
- **Costco Wholesale** (San Mateo) - **Costco Wholesale** (San Mateo)
- **Target** (Mountain View) - **Target** (Mountain View)
- **Sprouts Farmers Market** (Sunnyvale) - **Sprouts Farmers Market** (Sunnyvale)
- **Lucky Supermarket** (San Jose) - **Lucky Supermarket** (San Jose)
### Groceries (50+ items across 8 categories) ### Products (50+ items across 8 categories)
| Category | Items | Organic Options | | Category | Items | Organic Options |
|----------|-------|-----------------| |----------|-------|-----------------|
@ -190,7 +190,7 @@ After running the test data scripts:
The test data is designed to showcase all application features: The test data is designed to showcase all application features:
- Multiple shops and locations - Multiple shops and locations
- Diverse grocery categories - Diverse product categories
- Realistic shopping patterns - Realistic shopping patterns
- Price variations and organic options - Price variations and organic options
- Historical data for analytics - Historical data for analytics
@ -202,7 +202,7 @@ The test data is designed to showcase all application features:
You can modify the data arrays in `create_test_data.py` to create custom test scenarios: You can modify the data arrays in `create_test_data.py` to create custom test scenarios:
- Add more shops for specific regions - Add more shops for specific regions
- Include specialty grocery categories - Include specialty product categories
- Adjust price ranges for different markets - Adjust price ranges for different markets
- Create seasonal shopping patterns - Create seasonal shopping patterns

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Script to clean up all test data from the Grocery Tracker application. Script to clean up all test data from the Product Tracker application.
This will delete all shopping events, groceries, and shops. This will delete all shopping events, products, and shops.
""" """
import requests import requests
@ -42,37 +42,37 @@ def delete_all_shopping_events() -> int:
print(f" ❌ Error fetching shopping events: {e}") print(f" ❌ Error fetching shopping events: {e}")
return 0 return 0
def delete_all_groceries() -> int: def delete_all_products() -> int:
"""Delete all groceries and return the count of deleted groceries.""" """Delete all products and return the count of deleted products."""
print("🥬 Deleting all groceries...") print("🥬 Deleting all products...")
try: try:
# Get all groceries # Get all products
response = requests.get(f"{BASE_URL}/groceries/") response = requests.get(f"{BASE_URL}/products/")
if response.status_code != 200: if response.status_code != 200:
print(f" ❌ Failed to fetch groceries: {response.status_code}") print(f" ❌ Failed to fetch products: {response.status_code}")
return 0 return 0
groceries = response.json() products = response.json()
deleted_count = 0 deleted_count = 0
for grocery in groceries: for product in products:
try: try:
delete_response = requests.delete(f"{BASE_URL}/groceries/{grocery['id']}") delete_response = requests.delete(f"{BASE_URL}/products/{product['id']}")
if delete_response.status_code == 200: if delete_response.status_code == 200:
deleted_count += 1 deleted_count += 1
organic_label = "🌱" if grocery['organic'] else "🌾" organic_label = "🌱" if product['organic'] else "🌾"
print(f" ✅ Deleted grocery: {organic_label} {grocery['name']}") print(f" ✅ Deleted product: {organic_label} {product['name']}")
else: else:
print(f" ❌ Failed to delete grocery {grocery['name']}: {delete_response.status_code}") print(f" ❌ Failed to delete product {product['name']}: {delete_response.status_code}")
except Exception as e: except Exception as e:
print(f" ❌ Error deleting grocery {grocery['name']}: {e}") print(f" ❌ Error deleting product {product['name']}: {e}")
print(f" 📊 Deleted {deleted_count} groceries total\n") print(f" 📊 Deleted {deleted_count} products total\n")
return deleted_count return deleted_count
except Exception as e: except Exception as e:
print(f" ❌ Error fetching groceries: {e}") print(f" ❌ Error fetching products: {e}")
return 0 return 0
def delete_all_shops() -> int: def delete_all_shops() -> int:
@ -115,15 +115,15 @@ def get_current_data_summary():
try: try:
# Get counts # Get counts
shops_response = requests.get(f"{BASE_URL}/shops/") shops_response = requests.get(f"{BASE_URL}/shops/")
groceries_response = requests.get(f"{BASE_URL}/groceries/") products_response = requests.get(f"{BASE_URL}/products/")
events_response = requests.get(f"{BASE_URL}/shopping-events/") events_response = requests.get(f"{BASE_URL}/shopping-events/")
shops_count = len(shops_response.json()) if shops_response.status_code == 200 else 0 shops_count = len(shops_response.json()) if shops_response.status_code == 200 else 0
groceries_count = len(groceries_response.json()) if groceries_response.status_code == 200 else 0 products_count = len(products_response.json()) if products_response.status_code == 200 else 0
events_count = len(events_response.json()) if events_response.status_code == 200 else 0 events_count = len(events_response.json()) if events_response.status_code == 200 else 0
print(f"🏪 Shops: {shops_count}") print(f"🏪 Shops: {shops_count}")
print(f"🥬 Groceries: {groceries_count}") print(f"🥬 Products: {products_count}")
print(f"🛒 Shopping Events: {events_count}") print(f"🛒 Shopping Events: {events_count}")
if events_count > 0 and events_response.status_code == 200: if events_count > 0 and events_response.status_code == 200:
@ -132,7 +132,7 @@ def get_current_data_summary():
print(f"💰 Total spent: ${total_spent:.2f}") print(f"💰 Total spent: ${total_spent:.2f}")
print() print()
return shops_count, groceries_count, events_count return shops_count, products_count, events_count
except Exception as e: except Exception as e:
print(f"❌ Error getting data summary: {e}") print(f"❌ Error getting data summary: {e}")
@ -140,9 +140,9 @@ def get_current_data_summary():
def main(): def main():
"""Main function to clean up all test data.""" """Main function to clean up all test data."""
print("🧹 GROCERY TRACKER DATA CLEANUP") print("🧹 PRODUCT TRACKER DATA CLEANUP")
print("=" * 40) print("=" * 40)
print("This script will delete ALL data from the Grocery Tracker app.") print("This script will delete ALL data from the Product Tracker app.")
print("Make sure the backend server is running on http://localhost:8000\n") print("Make sure the backend server is running on http://localhost:8000\n")
try: try:
@ -155,9 +155,9 @@ def main():
print("✅ Connected to API server\n") print("✅ Connected to API server\n")
# Show current data # Show current data
shops_count, groceries_count, events_count = get_current_data_summary() shops_count, products_count, events_count = get_current_data_summary()
if shops_count == 0 and groceries_count == 0 and events_count == 0: if shops_count == 0 and products_count == 0 and events_count == 0:
print("✅ Database is already empty. Nothing to clean up!") print("✅ Database is already empty. Nothing to clean up!")
return return
@ -171,19 +171,19 @@ def main():
print("\n🧹 Starting cleanup process...\n") print("\n🧹 Starting cleanup process...\n")
# Delete in order: events -> groceries -> shops # Delete in order: events -> products -> shops
# (due to foreign key constraints) # (due to foreign key constraints)
deleted_events = delete_all_shopping_events() deleted_events = delete_all_shopping_events()
deleted_groceries = delete_all_groceries() deleted_products = delete_all_products()
deleted_shops = delete_all_shops() deleted_shops = delete_all_shops()
# Final summary # Final summary
print("📋 CLEANUP SUMMARY") print("📋 CLEANUP SUMMARY")
print("=" * 30) print("=" * 30)
print(f"🛒 Shopping Events deleted: {deleted_events}") print(f"🛒 Shopping Events deleted: {deleted_events}")
print(f"🥬 Groceries deleted: {deleted_groceries}") print(f"🥬 Products deleted: {deleted_products}")
print(f"🏪 Shops deleted: {deleted_shops}") print(f"🏪 Shops deleted: {deleted_shops}")
print(f"📊 Total items deleted: {deleted_events + deleted_groceries + deleted_shops}") print(f"📊 Total items deleted: {deleted_events + deleted_products + deleted_shops}")
print("\n🎉 Cleanup completed successfully!") print("\n🎉 Cleanup completed successfully!")
print("The database is now empty and ready for fresh data.") print("The database is now empty and ready for fresh data.")

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Script to create comprehensive test data for the Grocery Tracker application. Script to create comprehensive test data for the Product Tracker application.
This includes shops, groceries, and shopping events with realistic data. This includes shops, products, and shopping events with realistic data.
""" """
import requests import requests
@ -20,7 +20,7 @@ SHOPS_DATA = [
{"name": "Safeway", "city": "San Francisco", "address": "2020 Market St"}, {"name": "Safeway", "city": "San Francisco", "address": "2020 Market St"},
{"name": "Trader Joe's", "city": "Berkeley", "address": "1885 University Ave"}, {"name": "Trader Joe's", "city": "Berkeley", "address": "1885 University Ave"},
{"name": "Berkeley Bowl", "city": "Berkeley", "address": "2020 Oregon St"}, {"name": "Berkeley Bowl", "city": "Berkeley", "address": "2020 Oregon St"},
{"name": "Rainbow Grocery", "city": "San Francisco", "address": "1745 Folsom St"}, {"name": "Rainbow Product", "city": "San Francisco", "address": "1745 Folsom St"},
{"name": "Mollie Stone's Market", "city": "Palo Alto", "address": "164 S California Ave"}, {"name": "Mollie Stone's Market", "city": "Palo Alto", "address": "164 S California Ave"},
{"name": "Costco Wholesale", "city": "San Mateo", "address": "2300 S Norfolk St"}, {"name": "Costco Wholesale", "city": "San Mateo", "address": "2300 S Norfolk St"},
{"name": "Target", "city": "Mountain View", "address": "1200 El Camino Real"}, {"name": "Target", "city": "Mountain View", "address": "1200 El Camino Real"},
@ -119,13 +119,13 @@ PRICE_RANGES = {
def parse_arguments(): def parse_arguments():
"""Parse command line arguments.""" """Parse command line arguments."""
parser = argparse.ArgumentParser(description='Create test data for Grocery Tracker') parser = argparse.ArgumentParser(description='Create test data for Product Tracker')
parser.add_argument('--events', type=int, default=30, help='Number of shopping events to create (default: 30)') parser.add_argument('--events', type=int, default=30, help='Number of shopping events to create (default: 30)')
parser.add_argument('--days', type=int, default=90, help='Number of days back to generate events (default: 90)') parser.add_argument('--days', type=int, default=90, help='Number of days back to generate events (default: 90)')
parser.add_argument('--url', type=str, default=BASE_URL, help='API base URL (default: http://localhost:8000)') parser.add_argument('--url', type=str, default=BASE_URL, help='API base URL (default: http://localhost:8000)')
parser.add_argument('--shops-only', action='store_true', help='Create only shops') parser.add_argument('--shops-only', action='store_true', help='Create only shops')
parser.add_argument('--groceries-only', action='store_true', help='Create only groceries') parser.add_argument('--products-only', action='store_true', help='Create only products')
parser.add_argument('--events-only', action='store_true', help='Create only shopping events (requires existing shops and groceries)') parser.add_argument('--events-only', action='store_true', help='Create only shopping events (requires existing shops and products)')
parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output') parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output')
parser.add_argument('--dry-run', action='store_true', help='Show what would be created without actually creating it') parser.add_argument('--dry-run', action='store_true', help='Show what would be created without actually creating it')
return parser.parse_args() return parser.parse_args()
@ -171,43 +171,43 @@ def create_shops(base_url: str, verbose: bool = False, dry_run: bool = False) ->
print(f" 📊 Created {len(created_shops)} shops total\n") print(f" 📊 Created {len(created_shops)} shops total\n")
return created_shops return created_shops
def create_groceries(base_url: str, verbose: bool = False, dry_run: bool = False) -> List[Dict[str, Any]]: def create_products(base_url: str, verbose: bool = False, dry_run: bool = False) -> List[Dict[str, Any]]:
"""Create groceries and return the created grocery objects.""" """Create products and return the created product objects."""
print("🥬 Creating groceries...") print("🥬 Creating products...")
created_groceries = [] created_products = []
if dry_run: if dry_run:
print(" [DRY RUN] Would create the following groceries:") print(" [DRY RUN] Would create the following products:")
for grocery_data in GROCERIES_DATA: for product_data in GROCERIES_DATA:
organic_label = "🌱" if grocery_data['organic'] else "🌾" organic_label = "🌱" if product_data['organic'] else "🌾"
print(f" 📋 {organic_label} {grocery_data['name']} ({grocery_data['category']})") print(f" 📋 {organic_label} {product_data['name']} ({product_data['category']})")
return [] return []
for grocery_data in GROCERIES_DATA: for product_data in GROCERIES_DATA:
try: try:
if verbose: if verbose:
print(f" 🔄 Creating grocery: {grocery_data['name']}...") print(f" 🔄 Creating product: {product_data['name']}...")
response = requests.post(f"{base_url}/groceries/", json=grocery_data, timeout=10) response = requests.post(f"{base_url}/products/", json=product_data, timeout=10)
if response.status_code == 200: if response.status_code == 200:
grocery = response.json() product = response.json()
created_groceries.append(grocery) created_products.append(product)
organic_label = "🌱" if grocery['organic'] else "🌾" organic_label = "🌱" if product['organic'] else "🌾"
print(f" ✅ Created grocery: {organic_label} {grocery['name']} ({grocery['category']})") print(f" ✅ Created product: {organic_label} {product['name']} ({product['category']})")
else: else:
print(f" ❌ Failed to create grocery {grocery_data['name']}: {response.status_code}") print(f" ❌ Failed to create product {product_data['name']}: {response.status_code}")
if verbose: if verbose:
print(f" Response: {response.text}") print(f" Response: {response.text}")
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
print(f" ❌ Network error creating grocery {grocery_data['name']}: {e}") print(f" ❌ Network error creating product {product_data['name']}: {e}")
except Exception as e: except Exception as e:
print(f" ❌ Error creating grocery {grocery_data['name']}: {e}") print(f" ❌ Error creating product {product_data['name']}: {e}")
print(f" 📊 Created {len(created_groceries)} groceries total\n") print(f" 📊 Created {len(created_products)} products total\n")
return created_groceries return created_products
def generate_random_price(category: str, organic: bool = False) -> float: def generate_random_price(category: str, organic: bool = False) -> float:
"""Generate a random price for a grocery item based on category and organic status.""" """Generate a random price for a product item based on category and organic status."""
min_price, max_price = PRICE_RANGES.get(category, (1.99, 9.99)) min_price, max_price = PRICE_RANGES.get(category, (1.99, 9.99))
# Organic items are typically 20-50% more expensive # Organic items are typically 20-50% more expensive
@ -220,23 +220,23 @@ def generate_random_price(category: str, organic: bool = False) -> float:
return round(price, 2) return round(price, 2)
def get_existing_data(base_url: str) -> tuple[List[Dict], List[Dict]]: def get_existing_data(base_url: str) -> tuple[List[Dict], List[Dict]]:
"""Get existing shops and groceries from the API.""" """Get existing shops and products from the API."""
try: try:
shops_response = requests.get(f"{base_url}/shops/", timeout=10) shops_response = requests.get(f"{base_url}/shops/", timeout=10)
groceries_response = requests.get(f"{base_url}/groceries/", timeout=10) products_response = requests.get(f"{base_url}/products/", timeout=10)
shops = shops_response.json() if shops_response.status_code == 200 else [] shops = shops_response.json() if shops_response.status_code == 200 else []
groceries = groceries_response.json() if groceries_response.status_code == 200 else [] products = products_response.json() if products_response.status_code == 200 else []
return shops, groceries return shops, products
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
print(f" ❌ Error fetching existing data: {e}") print(f" ❌ Error fetching existing data: {e}")
return [], [] return [], []
def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: str, def create_shopping_events(shops: List[Dict], products: List[Dict], base_url: str,
num_events: int = 25, days_back: int = 90, num_events: int = 25, days_back: int = 90,
verbose: bool = False, dry_run: bool = False) -> List[Dict[str, Any]]: verbose: bool = False, dry_run: bool = False) -> List[Dict[str, Any]]:
"""Create shopping events with random groceries and realistic data.""" """Create shopping events with random products and realistic data."""
print(f"🛒 Creating {num_events} shopping events...") print(f"🛒 Creating {num_events} shopping events...")
created_events = [] created_events = []
@ -244,8 +244,8 @@ def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: s
print(" ❌ No shops available. Cannot create shopping events.") print(" ❌ No shops available. Cannot create shopping events.")
return [] return []
if not groceries: if not products:
print(" ❌ No groceries available. Cannot create shopping events.") print(" ❌ No products available. Cannot create shopping events.")
return [] return []
# Generate events over the specified time period # Generate events over the specified time period
@ -256,7 +256,7 @@ def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: s
print(f" [DRY RUN] Would create {num_events} shopping events over {days_back} days") print(f" [DRY RUN] Would create {num_events} shopping events over {days_back} days")
print(f" 📋 Date range: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}") print(f" 📋 Date range: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
print(f" 📋 Available shops: {len(shops)}") print(f" 📋 Available shops: {len(shops)}")
print(f" 📋 Available groceries: {len(groceries)}") print(f" 📋 Available products: {len(products)}")
return [] return []
for i in range(num_events): for i in range(num_events):
@ -272,32 +272,32 @@ def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: s
minutes=random.randint(0, 59) minutes=random.randint(0, 59)
) )
# Random number of groceries (2-8 items per shopping trip) # Random number of products (2-8 items per shopping trip)
num_groceries = random.randint(2, 8) num_products = random.randint(2, 8)
selected_groceries = random.sample(groceries, min(num_groceries, len(groceries))) selected_products = random.sample(products, min(num_products, len(products)))
# Create grocery items for this event # Create product items for this event
event_groceries = [] event_products = []
total_amount = 0.0 total_amount = 0.0
for grocery in selected_groceries: for product in selected_products:
# Random amount based on item type # Random amount based on item type
if grocery['weight_unit'] == 'piece': if product['weight_unit'] == 'piece':
amount = random.randint(1, 4) amount = random.randint(1, 4)
elif grocery['weight_unit'] == 'dozen': elif product['weight_unit'] == 'dozen':
amount = 1 amount = 1
elif grocery['weight_unit'] in ['box', 'head', 'bunch']: elif product['weight_unit'] in ['box', 'head', 'bunch']:
amount = random.randint(1, 2) amount = random.randint(1, 2)
elif grocery['weight_unit'] in ['gallon', 'l']: elif product['weight_unit'] in ['gallon', 'l']:
amount = 1 amount = 1
else: else:
amount = round(random.uniform(0.5, 3.0), 2) amount = round(random.uniform(0.5, 3.0), 2)
# Generate price based on category and organic status # Generate price based on category and organic status
price = generate_random_price(grocery['category'], grocery['organic']) price = generate_random_price(product['category'], product['organic'])
event_groceries.append({ event_products.append({
"grocery_id": grocery['id'], "product_id": product['id'],
"amount": amount, "amount": amount,
"price": price "price": price
}) })
@ -311,11 +311,11 @@ def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: s
notes = None notes = None
if random.random() < 0.3: if random.random() < 0.3:
note_options = [ note_options = [
"Weekly grocery shopping", "Weekly product shopping",
"Quick lunch ingredients", "Quick lunch ingredients",
"Dinner party prep", "Dinner party prep",
"Meal prep for the week", "Meal prep for the week",
"Emergency grocery run", "Emergency product run",
"Organic produce haul", "Organic produce haul",
"Bulk shopping trip", "Bulk shopping trip",
"Special occasion shopping", "Special occasion shopping",
@ -332,14 +332,14 @@ def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: s
"date": event_date.isoformat(), "date": event_date.isoformat(),
"total_amount": total_amount, "total_amount": total_amount,
"notes": notes, "notes": notes,
"groceries": event_groceries "products": event_products
} }
response = requests.post(f"{base_url}/shopping-events/", json=event_data, timeout=15) response = requests.post(f"{base_url}/shopping-events/", json=event_data, timeout=15)
if response.status_code == 200: if response.status_code == 200:
event = response.json() event = response.json()
created_events.append(event) created_events.append(event)
print(f" ✅ Created event #{event['id']}: {shop['name']} - ${total_amount:.2f} ({len(event_groceries)} items)") print(f" ✅ Created event #{event['id']}: {shop['name']} - ${total_amount:.2f} ({len(event_products)} items)")
else: else:
print(f" ❌ Failed to create shopping event: {response.status_code}") print(f" ❌ Failed to create shopping event: {response.status_code}")
if verbose: if verbose:
@ -352,7 +352,7 @@ def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: s
print(f" 📊 Created {len(created_events)} shopping events total\n") print(f" 📊 Created {len(created_events)} shopping events total\n")
return created_events return created_events
def print_summary(shops: List[Dict], groceries: List[Dict], events: List[Dict]): def print_summary(shops: List[Dict], products: List[Dict], events: List[Dict]):
"""Print a summary of the created test data.""" """Print a summary of the created test data."""
print("📋 TEST DATA SUMMARY") print("📋 TEST DATA SUMMARY")
print("=" * 50) print("=" * 50)
@ -361,13 +361,13 @@ def print_summary(shops: List[Dict], groceries: List[Dict], events: List[Dict]):
for shop in shops: for shop in shops:
print(f"{shop['name']} ({shop['city']})") print(f"{shop['name']} ({shop['city']})")
print(f"\n🥬 Groceries: {len(groceries)}") print(f"\n🥬 Products: {len(products)}")
categories = {} categories = {}
for grocery in groceries: for product in products:
category = grocery['category'] category = product['category']
if category not in categories: if category not in categories:
categories[category] = [] categories[category] = []
categories[category].append(grocery) categories[category].append(product)
for category, items in categories.items(): for category, items in categories.items():
organic_count = sum(1 for item in items if item['organic']) organic_count = sum(1 for item in items if item['organic'])
@ -394,7 +394,7 @@ def main():
"""Main function to create all test data.""" """Main function to create all test data."""
args = parse_arguments() args = parse_arguments()
print("🚀 GROCERY TRACKER TEST DATA GENERATOR") print("🚀 PRODUCT TRACKER TEST DATA GENERATOR")
print("=" * 50) print("=" * 50)
if args.dry_run: if args.dry_run:
@ -415,28 +415,28 @@ def main():
print("✅ Connected to API server\n") print("✅ Connected to API server\n")
shops = [] shops = []
groceries = [] products = []
events = [] events = []
# Create data based on arguments # Create data based on arguments
if args.shops_only: if args.shops_only:
shops = create_shops(args.url, args.verbose, args.dry_run) shops = create_shops(args.url, args.verbose, args.dry_run)
elif args.groceries_only: elif args.products_only:
groceries = create_groceries(args.url, args.verbose, args.dry_run) products = create_products(args.url, args.verbose, args.dry_run)
elif args.events_only: elif args.events_only:
# Get existing data for events # Get existing data for events
shops, groceries = get_existing_data(args.url) shops, products = get_existing_data(args.url)
events = create_shopping_events(shops, groceries, args.url, args.events, args.days, args.verbose, args.dry_run) events = create_shopping_events(shops, products, args.url, args.events, args.days, args.verbose, args.dry_run)
else: else:
# Create all data # Create all data
shops = create_shops(args.url, args.verbose, args.dry_run) shops = create_shops(args.url, args.verbose, args.dry_run)
groceries = create_groceries(args.url, args.verbose, args.dry_run) products = create_products(args.url, args.verbose, args.dry_run)
if shops and groceries: if shops and products:
events = create_shopping_events(shops, groceries, args.url, args.events, args.days, args.verbose, args.dry_run) events = create_shopping_events(shops, products, args.url, args.events, args.days, args.verbose, args.dry_run)
# Print summary # Print summary
if not args.dry_run: if not args.dry_run:
print_summary(shops, groceries, events) print_summary(shops, products, events)
if args.dry_run: if args.dry_run:
print("\n🔍 Dry run completed. Use without --dry-run to actually create the data.") print("\n🔍 Dry run completed. Use without --dry-run to actually create the data.")

View File

@ -11,7 +11,7 @@ DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL: if not DATABASE_URL:
# Default to SQLite for development if no PostgreSQL URL is provided # Default to SQLite for development if no PostgreSQL URL is provided
DATABASE_URL = "sqlite:///./grocery_tracker.db" DATABASE_URL = "sqlite:///./product_tracker.db"
print("🔄 Using SQLite database for development") print("🔄 Using SQLite database for development")
else: else:
print(f"🐘 Using PostgreSQL database") print(f"🐘 Using PostgreSQL database")

View File

@ -1,9 +1,9 @@
# Database Configuration # Database Configuration
# Option 1: PostgreSQL (for production) # Option 1: PostgreSQL (for production)
# DATABASE_URL=postgresql://username:password@localhost:5432/grocery_tracker # DATABASE_URL=postgresql://username:password@localhost:5432/product_tracker
# Option 2: SQLite (for development - default if DATABASE_URL is not set) # Option 2: SQLite (for development - default if DATABASE_URL is not set)
# DATABASE_URL=sqlite:///./grocery_tracker.db # DATABASE_URL=sqlite:///./product_tracker.db
# Authentication (optional for basic setup) # Authentication (optional for basic setup)
SECRET_KEY=your-secret-key-here SECRET_KEY=your-secret-key-here

View File

@ -10,8 +10,8 @@ from database import engine, get_db
models.Base.metadata.create_all(bind=engine) models.Base.metadata.create_all(bind=engine)
app = FastAPI( app = FastAPI(
title="Grocery Tracker API", title="Product Tracker API",
description="API for tracking grocery prices and shopping events", description="API for tracking product prices and shopping events",
version="1.0.0" version="1.0.0"
) )
@ -25,22 +25,22 @@ app.add_middleware(
) )
def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> schemas.ShoppingEventResponse: def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> schemas.ShoppingEventResponse:
"""Build a shopping event response with groceries from the association table""" """Build a shopping event response with products from the association table"""
# Get groceries with their event-specific data # Get products with their event-specific data
grocery_data = db.execute( product_data = db.execute(
text(""" text("""
SELECT g.id, g.name, g.category, g.organic, g.weight, g.weight_unit, SELECT p.id, p.name, p.category, p.organic, p.weight, p.weight_unit,
seg.amount, seg.price sep.amount, sep.price
FROM groceries g FROM products p
JOIN shopping_event_groceries seg ON g.id = seg.grocery_id JOIN shopping_event_products sep ON p.id = sep.product_id
WHERE seg.shopping_event_id = :event_id WHERE sep.shopping_event_id = :event_id
"""), """),
{"event_id": event.id} {"event_id": event.id}
).fetchall() ).fetchall()
# Convert to GroceryWithEventData objects # Convert to ProductWithEventData objects
groceries_with_data = [ products_with_data = [
schemas.GroceryWithEventData( schemas.ProductWithEventData(
id=row.id, id=row.id,
name=row.name, name=row.name,
category=row.category, category=row.category,
@ -50,7 +50,7 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
amount=row.amount, amount=row.amount,
price=row.price price=row.price
) )
for row in grocery_data for row in product_data
] ]
return schemas.ShoppingEventResponse( return schemas.ShoppingEventResponse(
@ -61,58 +61,58 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
notes=event.notes, notes=event.notes,
created_at=event.created_at, created_at=event.created_at,
shop=event.shop, shop=event.shop,
groceries=groceries_with_data products=products_with_data
) )
# Root endpoint # Root endpoint
@app.get("/") @app.get("/")
def read_root(): def read_root():
return {"message": "Grocery Tracker API", "version": "1.0.0"} return {"message": "Product Tracker API", "version": "1.0.0"}
# Grocery endpoints # Product endpoints
@app.post("/groceries/", response_model=schemas.Grocery) @app.post("/products/", response_model=schemas.Product)
def create_grocery(grocery: schemas.GroceryCreate, db: Session = Depends(get_db)): def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
db_grocery = models.Grocery(**grocery.dict()) db_product = models.Product(**product.dict())
db.add(db_grocery) db.add(db_product)
db.commit() db.commit()
db.refresh(db_grocery) db.refresh(db_product)
return db_grocery return db_product
@app.get("/groceries/", response_model=List[schemas.Grocery]) @app.get("/products/", response_model=List[schemas.Product])
def read_groceries(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): def read_products(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
groceries = db.query(models.Grocery).offset(skip).limit(limit).all() products = db.query(models.Product).offset(skip).limit(limit).all()
return groceries return products
@app.get("/groceries/{grocery_id}", response_model=schemas.Grocery) @app.get("/products/{product_id}", response_model=schemas.Product)
def read_grocery(grocery_id: int, db: Session = Depends(get_db)): def read_product(product_id: int, db: Session = Depends(get_db)):
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first() product = db.query(models.Product).filter(models.Product.id == product_id).first()
if grocery is None: if product is None:
raise HTTPException(status_code=404, detail="Grocery not found") raise HTTPException(status_code=404, detail="Product not found")
return grocery return product
@app.put("/groceries/{grocery_id}", response_model=schemas.Grocery) @app.put("/products/{product_id}", response_model=schemas.Product)
def update_grocery(grocery_id: int, grocery_update: schemas.GroceryUpdate, db: Session = Depends(get_db)): def update_product(product_id: int, product_update: schemas.ProductUpdate, db: Session = Depends(get_db)):
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first() product = db.query(models.Product).filter(models.Product.id == product_id).first()
if grocery is None: if product is None:
raise HTTPException(status_code=404, detail="Grocery not found") raise HTTPException(status_code=404, detail="Product not found")
update_data = grocery_update.dict(exclude_unset=True) update_data = product_update.dict(exclude_unset=True)
for field, value in update_data.items(): for field, value in update_data.items():
setattr(grocery, field, value) setattr(product, field, value)
db.commit() db.commit()
db.refresh(grocery) db.refresh(product)
return grocery return product
@app.delete("/groceries/{grocery_id}") @app.delete("/products/{product_id}")
def delete_grocery(grocery_id: int, db: Session = Depends(get_db)): def delete_product(product_id: int, db: Session = Depends(get_db)):
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first() product = db.query(models.Product).filter(models.Product.id == product_id).first()
if grocery is None: if product is None:
raise HTTPException(status_code=404, detail="Grocery not found") raise HTTPException(status_code=404, detail="Product not found")
db.delete(grocery) db.delete(product)
db.commit() db.commit()
return {"message": "Grocery deleted successfully"} return {"message": "Product deleted successfully"}
# Shop endpoints # Shop endpoints
@app.post("/shops/", response_model=schemas.Shop) @app.post("/shops/", response_model=schemas.Shop)
@ -178,19 +178,19 @@ def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depe
db.commit() db.commit()
db.refresh(db_event) db.refresh(db_event)
# Add groceries to the event # Add products to the event
for grocery_item in event.groceries: for product_item in event.products:
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_item.grocery_id).first() product = db.query(models.Product).filter(models.Product.id == product_item.product_id).first()
if grocery is None: if product is None:
raise HTTPException(status_code=404, detail=f"Grocery with id {grocery_item.grocery_id} not found") raise HTTPException(status_code=404, detail=f"Product with id {product_item.product_id} not found")
# Insert into association table # Insert into association table
db.execute( db.execute(
models.shopping_event_groceries.insert().values( models.shopping_event_products.insert().values(
shopping_event_id=db_event.id, shopping_event_id=db_event.id,
grocery_id=grocery_item.grocery_id, product_id=product_item.product_id,
amount=grocery_item.amount, amount=product_item.amount,
price=grocery_item.price price=product_item.price
) )
) )
@ -228,26 +228,26 @@ def update_shopping_event(event_id: int, event_update: schemas.ShoppingEventCrea
event.total_amount = event_update.total_amount event.total_amount = event_update.total_amount
event.notes = event_update.notes event.notes = event_update.notes
# Remove existing grocery associations # Remove existing product associations
db.execute( db.execute(
models.shopping_event_groceries.delete().where( models.shopping_event_products.delete().where(
models.shopping_event_groceries.c.shopping_event_id == event_id models.shopping_event_products.c.shopping_event_id == event_id
) )
) )
# Add new grocery associations # Add new product associations
for grocery_item in event_update.groceries: for product_item in event_update.products:
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_item.grocery_id).first() product = db.query(models.Product).filter(models.Product.id == product_item.product_id).first()
if grocery is None: if product is None:
raise HTTPException(status_code=404, detail=f"Grocery with id {grocery_item.grocery_id} not found") raise HTTPException(status_code=404, detail=f"Product with id {product_item.product_id} not found")
# Insert into association table # Insert into association table
db.execute( db.execute(
models.shopping_event_groceries.insert().values( models.shopping_event_products.insert().values(
shopping_event_id=event_id, shopping_event_id=event_id,
grocery_id=grocery_item.grocery_id, product_id=product_item.product_id,
amount=grocery_item.amount, amount=product_item.amount,
price=grocery_item.price price=product_item.price
) )
) )
@ -261,10 +261,10 @@ def delete_shopping_event(event_id: int, db: Session = Depends(get_db)):
if event is None: if event is None:
raise HTTPException(status_code=404, detail="Shopping event not found") raise HTTPException(status_code=404, detail="Shopping event not found")
# Delete grocery associations first # Delete product associations first
db.execute( db.execute(
models.shopping_event_groceries.delete().where( models.shopping_event_products.delete().where(
models.shopping_event_groceries.c.shopping_event_id == event_id models.shopping_event_products.c.shopping_event_id == event_id
) )
) )

View File

@ -6,19 +6,19 @@ from datetime import datetime
Base = declarative_base() Base = declarative_base()
# Association table for many-to-many relationship between shopping events and groceries # Association table for many-to-many relationship between shopping events and products
shopping_event_groceries = Table( shopping_event_products = Table(
'shopping_event_groceries', 'shopping_event_products',
Base.metadata, Base.metadata,
Column('id', Integer, primary_key=True, autoincrement=True), # Artificial primary key Column('id', Integer, primary_key=True, autoincrement=True), # Artificial primary key
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), nullable=False), Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), nullable=False),
Column('grocery_id', Integer, ForeignKey('groceries.id'), nullable=False), Column('product_id', Integer, ForeignKey('products.id'), nullable=False),
Column('amount', Float, nullable=False), # Amount of this grocery bought in this event Column('amount', Float, nullable=False), # Amount of this product bought in this event
Column('price', Float, nullable=False) # Price of this grocery at the time of this shopping event Column('price', Float, nullable=False) # Price of this product at the time of this shopping event
) )
class Grocery(Base): class Product(Base):
__tablename__ = "groceries" __tablename__ = "products"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True) name = Column(String, nullable=False, index=True)
@ -30,7 +30,7 @@ class Grocery(Base):
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships # Relationships
shopping_events = relationship("ShoppingEvent", secondary=shopping_event_groceries, back_populates="groceries") shopping_events = relationship("ShoppingEvent", secondary=shopping_event_products, back_populates="products")
class Shop(Base): class Shop(Base):
__tablename__ = "shops" __tablename__ = "shops"
@ -58,4 +58,4 @@ class ShoppingEvent(Base):
# Relationships # Relationships
shop = relationship("Shop", back_populates="shopping_events") shop = relationship("Shop", back_populates="shopping_events")
groceries = relationship("Grocery", secondary=shopping_event_groceries, back_populates="shopping_events") products = relationship("Product", secondary=shopping_event_products, back_populates="shopping_events")

View File

@ -22,7 +22,7 @@ def main():
backend_dir = Path(__file__).parent backend_dir = Path(__file__).parent
os.chdir(backend_dir) os.chdir(backend_dir)
print("🍃 Starting Grocery Tracker Backend Development Server") print("🍃 Starting Product Tracker Backend Development Server")
print("=" * 50) print("=" * 50)
# Check if virtual environment exists # Check if virtual environment exists

View File

@ -3,24 +3,24 @@ from typing import Optional, List
from datetime import datetime from datetime import datetime
# Base schemas # Base schemas
class GroceryBase(BaseModel): class ProductBase(BaseModel):
name: str name: str
category: str category: str
organic: bool = False organic: bool = False
weight: Optional[float] = None weight: Optional[float] = None
weight_unit: str = "g" weight_unit: str = "g"
class GroceryCreate(GroceryBase): class ProductCreate(ProductBase):
pass pass
class GroceryUpdate(BaseModel): class ProductUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None
organic: Optional[bool] = None organic: Optional[bool] = None
weight: Optional[float] = None weight: Optional[float] = None
weight_unit: Optional[str] = None weight_unit: Optional[str] = None
class Grocery(GroceryBase): class Product(ProductBase):
id: int id: int
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
@ -51,12 +51,12 @@ class Shop(ShopBase):
from_attributes = True from_attributes = True
# Shopping Event schemas # Shopping Event schemas
class GroceryInEvent(BaseModel): class ProductInEvent(BaseModel):
grocery_id: int product_id: int
amount: float = Field(..., gt=0) amount: float = Field(..., gt=0)
price: float = Field(..., ge=0) # Price at the time of this shopping event (allow free items) price: float = Field(..., ge=0) # Price at the time of this shopping event (allow free items)
class GroceryWithEventData(BaseModel): class ProductWithEventData(BaseModel):
id: int id: int
name: str name: str
category: str category: str
@ -76,21 +76,21 @@ class ShoppingEventBase(BaseModel):
notes: Optional[str] = None notes: Optional[str] = None
class ShoppingEventCreate(ShoppingEventBase): class ShoppingEventCreate(ShoppingEventBase):
groceries: List[GroceryInEvent] = [] products: List[ProductInEvent] = []
class ShoppingEventUpdate(BaseModel): class ShoppingEventUpdate(BaseModel):
shop_id: Optional[int] = None shop_id: Optional[int] = None
date: Optional[datetime] = None date: Optional[datetime] = None
total_amount: Optional[float] = Field(None, ge=0) total_amount: Optional[float] = Field(None, ge=0)
notes: Optional[str] = None notes: Optional[str] = None
groceries: Optional[List[GroceryInEvent]] = None products: Optional[List[ProductInEvent]] = None
class ShoppingEventResponse(ShoppingEventBase): class ShoppingEventResponse(ShoppingEventBase):
id: int id: int
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
shop: Shop shop: Shop
groceries: List[GroceryWithEventData] = [] products: List[ProductWithEventData] = []
class Config: class Config:
from_attributes = True from_attributes = True

603
database_schema dev.drawio Normal file
View File

@ -0,0 +1,603 @@
<mxfile host="65bd71144e">
<diagram name="Product Tracker Database Schema" id="database-schema">
<mxGraphModel dx="999" dy="529" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="shop-event-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="71" target="43" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="300" y="470" as="sourcePoint"/>
<mxPoint x="350" y="420" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="shop-event-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1">
<mxGeometry x="960" y="399" width="40" height="30" as="geometry"/>
</mxCell>
<mxCell id="event-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="40" target="99" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="620" y="310" as="sourcePoint"/>
<mxPoint x="720" y="270" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="event-association-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1">
<mxGeometry x="900" y="230" width="40" height="30" as="geometry"/>
</mxCell>
<mxCell id="product-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" parent="1" source="161" target="102" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1020" y="550" as="sourcePoint"/>
<mxPoint x="720" y="290" as="targetPoint"/>
<Array as="points"/>
</mxGeometry>
</mxCell>
<mxCell id="product-association-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1">
<mxGeometry x="390" y="160" width="40" height="30" as="geometry"/>
</mxCell>
<mxCell id="diagram-title" value="Product Tracker Database Schema" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=20;fontStyle=1;" parent="1" vertex="1">
<mxGeometry x="400" y="20" width="320" height="40" as="geometry"/>
</mxCell>
<mxCell id="2" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;products&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="390" y="440" width="180" height="180" as="geometry"/>
</mxCell>
<mxCell id="3" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="2" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="4" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="3" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="5" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="3" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="6" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="7" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="6" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="8" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="6" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="144" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="145" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="144" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="146" value="category_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="144" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="21" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="22" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="21" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="23" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="21" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="15" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="150" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="16" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="15" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="17" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="15" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="39" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;shopping_events&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="580" y="150" width="180" height="240" as="geometry"/>
</mxCell>
<mxCell id="40" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="39" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="41" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="40" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="42" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="40" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="43" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="44" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="43" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="45" value="shop_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="43" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="46" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="47" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="46" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="48" value="date: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="46" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="49" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="50" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="49" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="51" value="total_amount: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="49" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="52" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="150" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="53" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="52" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="54" value="notes: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="52" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="58" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="180" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="59" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="58" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="60" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="58" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="111" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="210" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="112" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="111" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="113" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="111" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="70" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;shops&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="80" y="120" width="180" height="210" as="geometry"/>
</mxCell>
<mxCell id="71" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="70" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="72" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="71" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="73" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="71" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="74" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="75" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="74" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="76" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="74" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="77" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="78" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="77" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="79" value="city: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="77" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="80" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="81" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="80" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="82" value="address: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="80" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="89" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
<mxGeometry y="150" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="90" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="89" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="91" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="89" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="92" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;strokeColor=#b85450;" parent="70" vertex="1">
<mxGeometry y="180" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="93" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="92" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="94" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="92" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="95" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;shopping_event_products&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="1090" y="260" width="240" height="180" as="geometry"/>
</mxCell>
<mxCell id="96" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="95" vertex="1">
<mxGeometry y="30" width="240" height="30" as="geometry"/>
</mxCell>
<mxCell id="97" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="96" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="98" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="96" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="99" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
<mxGeometry y="60" width="240" height="30" as="geometry"/>
</mxCell>
<mxCell id="100" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="99" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="101" value="shopping_event_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="99" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="102" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
<mxGeometry y="90" width="240" height="30" as="geometry"/>
</mxCell>
<mxCell id="103" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="104" value="product_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="105" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
<mxGeometry y="120" width="240" height="30" as="geometry"/>
</mxCell>
<mxCell id="106" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="105" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="107" value="amount: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="105" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="108" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
<mxGeometry y="150" width="240" height="30" as="geometry"/>
</mxCell>
<mxCell id="109" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="108" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="110" value="price: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="108" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="119" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;grocerie_categories&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="80" y="440" width="180" height="150" as="geometry"/>
</mxCell>
<mxCell id="120" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="119" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="121" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="120" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="122" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="120" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="123" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="119" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="124" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="123" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="125" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="123" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="138" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="119" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="139" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="138" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="140" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="138" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="141" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="119" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="142" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="141" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="143" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="141" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="160" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;products&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="710" y="445" width="180" height="300" as="geometry"/>
</mxCell>
<mxCell id="161" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="160" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="162" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="161" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="163" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="161" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="164" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="165" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="164" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="166" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="164" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="170" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="171" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="170" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="172" value="product_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="170" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="188" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="189" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="188" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="190" value="brand_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="188" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="173" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
<mxGeometry y="150" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="174" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="173" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="175" value="organic: BOOLEAN" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="173" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="176" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
<mxGeometry y="180" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="177" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="176" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="178" value="weight: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="176" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="179" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
<mxGeometry y="210" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="180" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="179" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="181" value="weight_unit: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="179" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="182" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
<mxGeometry y="240" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="183" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="182" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="184" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="182" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="185" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
<mxGeometry y="270" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="186" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="185" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="187" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="185" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="191" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;brands&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="390" y="790" width="180" height="150" as="geometry"/>
</mxCell>
<mxCell id="192" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="191" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="193" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="192" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="194" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="192" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="195" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="191" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="196" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="195" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="197" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="195" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="213" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="191" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="214" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="213" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="215" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="213" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="216" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="191" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="217" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="216" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="218" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="216" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="219" value="" style="endArrow=ERmany;html=1;rounded=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" parent="1" source="192" target="188" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="900" y="500" as="sourcePoint"/>
<mxPoint x="1100" y="375" as="targetPoint"/>
<Array as="points"/>
</mxGeometry>
</mxCell>
<mxCell id="220" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="3" target="170" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="580" y="845" as="sourcePoint"/>
<mxPoint x="670" y="560" as="targetPoint"/>
<Array as="points"/>
</mxGeometry>
</mxCell>
<mxCell id="221" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="120" target="144" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="580" y="495" as="sourcePoint"/>
<mxPoint x="720" y="560" as="targetPoint"/>
<Array as="points"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View File

@ -1,5 +1,5 @@
<mxfile host="65bd71144e"> <mxfile host="65bd71144e">
<diagram name="Grocery Tracker Database Schema" id="database-schema"> <diagram name="Product Tracker Database Schema" id="database-schema">
<mxGraphModel dx="547" dy="520" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0"> <mxGraphModel dx="547" dy="520" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
<root> <root>
<mxCell id="0"/> <mxCell id="0"/>
@ -22,7 +22,7 @@
<mxCell id="event-association-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1"> <mxCell id="event-association-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1">
<mxGeometry x="650" y="280" width="40" height="30" as="geometry"/> <mxGeometry x="650" y="280" width="40" height="30" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="grocery-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="3" target="102" edge="1"> <mxCell id="product-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="3" target="102" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="280" y="150" as="sourcePoint"/> <mxPoint x="280" y="150" as="sourcePoint"/>
<mxPoint x="720" y="290" as="targetPoint"/> <mxPoint x="720" y="290" as="targetPoint"/>
@ -32,7 +32,7 @@
</Array> </Array>
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="grocery-association-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1"> <mxCell id="product-association-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1">
<mxGeometry x="630" y="220" width="40" height="30" as="geometry"/> <mxGeometry x="630" y="220" width="40" height="30" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="legend" value="" style="swimlane;fontStyle=1;childLayout=stackLayout;horizontal=1;startSize=30;horizontalStack=0;resizeParent=1;resizeParentMax=0;resizeLast=0;collapsible=1;marginBottom=0;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" parent="1" vertex="1"> <mxCell id="legend" value="" style="swimlane;fontStyle=1;childLayout=stackLayout;horizontal=1;startSize=30;horizontalStack=0;resizeParent=1;resizeParentMax=0;resizeLast=0;collapsible=1;marginBottom=0;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" parent="1" vertex="1">
@ -50,10 +50,10 @@
<mxCell id="legend-relation" value="1:N = One-to-Many Relationship" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" parent="legend" vertex="1"> <mxCell id="legend-relation" value="1:N = One-to-Many Relationship" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" parent="legend" vertex="1">
<mxGeometry y="90" width="300" height="20" as="geometry"/> <mxGeometry y="90" width="300" height="20" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="diagram-title" value="Grocery Tracker Database Schema" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=20;fontStyle=1;" parent="1" vertex="1"> <mxCell id="diagram-title" value="Product Tracker Database Schema" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=20;fontStyle=1;" parent="1" vertex="1">
<mxGeometry x="400" y="20" width="320" height="40" as="geometry"/> <mxGeometry x="400" y="20" width="320" height="40" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="2" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;groceries&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1"> <mxCell id="2" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;products&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="70" y="70" width="180" height="270" as="geometry"/> <mxGeometry x="70" y="70" width="180" height="270" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="3" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="2" vertex="1"> <mxCell id="3" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="2" vertex="1">
@ -335,7 +335,7 @@
<mxRectangle width="150" height="30" as="alternateBounds"/> <mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="95" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;shopping_event_groceries&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1"> <mxCell id="95" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;shopping_event_products&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="810" y="200" width="240" height="180" as="geometry"/> <mxGeometry x="810" y="200" width="240" height="180" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="96" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="95" vertex="1"> <mxCell id="96" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="95" vertex="1">
@ -372,7 +372,7 @@
<mxRectangle width="30" height="30" as="alternateBounds"/> <mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="104" value="grocery_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1"> <mxCell id="104" value="product_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry"> <mxGeometry x="30" width="210" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/> <mxRectangle width="210" height="30" as="alternateBounds"/>
</mxGeometry> </mxGeometry>

View File

@ -1,11 +1,11 @@
{ {
"name": "grocery-tracker-frontend", "name": "product-tracker-frontend",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "grocery-tracker-frontend", "name": "product-tracker-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@types/node": "^20.10.5", "@types/node": "^20.10.5",

View File

@ -1,5 +1,5 @@
{ {
"name": "grocery-tracker-frontend", "name": "product-tracker-frontend",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {

View File

@ -7,9 +7,9 @@
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta
name="description" name="description"
content="Track grocery prices and shopping events" content="Track product prices and shopping events"
/> />
<title>Grocery Tracker</title> <title>Product Tracker</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
echo "🎯 Setting up Grocery Tracker Frontend" echo "🎯 Setting up Product Tracker Frontend"
echo "======================================" echo "======================================"
# Check if Node.js is installed # Check if Node.js is installed

View File

@ -1,67 +1,64 @@
import React from 'react'; import React from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
import GroceryList from './components/GroceryList';
import ShopList from './components/ShopList';
import ShoppingEventForm from './components/ShoppingEventForm';
import ShoppingEventList from './components/ShoppingEventList';
import Dashboard from './components/Dashboard'; import Dashboard from './components/Dashboard';
import ProductList from './components/ProductList';
import ShopList from './components/ShopList';
import ShoppingEventList from './components/ShoppingEventList';
import ShoppingEventForm from './components/ShoppingEventForm';
function Navigation() {
const location = useLocation();
const isActive = (path: string) => {
return location.pathname === path;
};
return (
<nav className="bg-blue-600 text-white p-4">
<div className="container mx-auto flex justify-between items-center">
<Link to="/" className="text-xl font-bold">
Product Tracker
</Link>
<div className="space-x-4">
<Link
to="/"
className={`px-3 py-2 rounded ${isActive('/') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
>
Dashboard
</Link>
<Link
to="/products"
className={`px-3 py-2 rounded ${isActive('/products') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
>
Products
</Link>
<Link
to="/shops"
className={`px-3 py-2 rounded ${isActive('/shops') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
>
Shops
</Link>
<Link
to="/shopping-events"
className={`px-3 py-2 rounded ${isActive('/shopping-events') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
>
Shopping Events
</Link>
</div>
</div>
</nav>
);
}
function App() { function App() {
return ( return (
<Router> <Router>
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-100">
{/* Navigation */} <Navigation />
<nav className="bg-white shadow-lg"> <main className="container mx-auto py-8 px-4">
<div className="max-w-7xl mx-auto px-4">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<h1 className="text-xl font-bold text-gray-800">
🛒 Grocery Tracker
</h1>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
<Link
to="/"
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
>
Dashboard
</Link>
<Link
to="/groceries"
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
>
Groceries
</Link>
<Link
to="/shops"
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
>
Shops
</Link>
<Link
to="/shopping-events"
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
>
Shopping Events
</Link>
<Link
to="/add-purchase"
className="bg-blue-500 hover:bg-blue-700 text-white inline-flex items-center px-3 py-2 text-sm font-medium rounded-md"
>
Add New Event
</Link>
</div>
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/groceries" element={<GroceryList />} /> <Route path="/products" element={<ProductList />} />
<Route path="/shops" element={<ShopList />} /> <Route path="/shops" element={<ShopList />} />
<Route path="/shopping-events" element={<ShoppingEventList />} /> <Route path="/shopping-events" element={<ShoppingEventList />} />
<Route path="/shopping-events/:id/edit" element={<ShoppingEventForm />} /> <Route path="/shopping-events/:id/edit" element={<ShoppingEventForm />} />

View File

@ -1,15 +1,15 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { groceryApi } from '../services/api'; import { productApi } from '../services/api';
import { Grocery } from '../types'; import { Product } from '../types';
interface AddGroceryModalProps { interface AddProductModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onGroceryAdded: () => void; onProductAdded: () => void;
editGrocery?: Grocery | null; editProduct?: Product | null;
} }
interface GroceryFormData { interface ProductFormData {
name: string; name: string;
category: string; category: string;
organic: boolean; organic: boolean;
@ -17,8 +17,8 @@ interface GroceryFormData {
weight_unit: string; weight_unit: string;
} }
const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGroceryAdded, editGrocery }) => { const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct }) => {
const [formData, setFormData] = useState<GroceryFormData>({ const [formData, setFormData] = useState<ProductFormData>({
name: '', name: '',
category: '', category: '',
organic: false, organic: false,
@ -37,16 +37,16 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
// Populate form when editing // Populate form when editing
useEffect(() => { useEffect(() => {
if (editGrocery) { if (editProduct) {
setFormData({ setFormData({
name: editGrocery.name, name: editProduct.name,
category: editGrocery.category, category: editProduct.category,
organic: editGrocery.organic, organic: editProduct.organic,
weight: editGrocery.weight, weight: editProduct.weight,
weight_unit: editGrocery.weight_unit weight_unit: editProduct.weight_unit
}); });
} else { } else {
// Reset form for adding new grocery // Reset form for adding new product
setFormData({ setFormData({
name: '', name: '',
category: '', category: '',
@ -56,7 +56,7 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
}); });
} }
setError(''); setError('');
}, [editGrocery, isOpen]); }, [editProduct, isOpen]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -69,17 +69,17 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
setLoading(true); setLoading(true);
setError(''); setError('');
const groceryData = { const productData = {
...formData, ...formData,
weight: formData.weight || undefined weight: formData.weight || undefined
}; };
if (editGrocery) { if (editProduct) {
// Update existing grocery // Update existing product
await groceryApi.update(editGrocery.id, groceryData); await productApi.update(editProduct.id, productData);
} else { } else {
// Create new grocery // Create new product
await groceryApi.create(groceryData); await productApi.create(productData);
} }
// Reset form // Reset form
@ -91,11 +91,11 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
weight_unit: 'piece' weight_unit: 'piece'
}); });
onGroceryAdded(); onProductAdded();
onClose(); onClose();
} catch (err) { } catch (err) {
setError(`Failed to ${editGrocery ? 'update' : 'add'} grocery. Please try again.`); setError(`Failed to ${editProduct ? 'update' : 'add'} product. Please try again.`);
console.error(`Error ${editGrocery ? 'updating' : 'adding'} grocery:`, err); console.error(`Error ${editProduct ? 'updating' : 'adding'} product:`, err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -119,7 +119,7 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
<div className="mt-3"> <div className="mt-3">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900"> <h3 className="text-lg font-medium text-gray-900">
{editGrocery ? 'Edit Grocery' : 'Add New Grocery'} {editProduct ? 'Edit Product' : 'Add New Product'}
</h3> </h3>
<button <button
onClick={onClose} onClick={onClose}
@ -236,8 +236,8 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading {loading
? (editGrocery ? 'Updating...' : 'Adding...') ? (editProduct ? 'Updating...' : 'Adding...')
: (editGrocery ? 'Update Grocery' : 'Add Grocery') : (editProduct ? 'Update Product' : 'Add Product')
} }
</button> </button>
</div> </div>
@ -248,4 +248,4 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
); );
}; };
export default AddGroceryModal; export default AddProductModal;

View File

@ -32,7 +32,7 @@ const Dashboard: React.FC = () => {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1> <h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600">Welcome to your grocery tracker!</p> <p className="text-gray-600">Welcome to your product tracker!</p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
@ -102,7 +102,7 @@ const Dashboard: React.FC = () => {
<div className="p-6"> <div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button <button
onClick={() => navigate('/add-purchase')} onClick={() => navigate('/shopping-events')}
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
> >
<div className="p-2 bg-blue-100 rounded-md mr-3"> <div className="p-2 bg-blue-100 rounded-md mr-3">
@ -117,7 +117,7 @@ const Dashboard: React.FC = () => {
</button> </button>
<button <button
onClick={() => navigate('/groceries?add=true')} onClick={() => navigate('/products?add=true')}
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
> >
<div className="p-2 bg-green-100 rounded-md mr-3"> <div className="p-2 bg-green-100 rounded-md mr-3">
@ -126,8 +126,8 @@ const Dashboard: React.FC = () => {
</svg> </svg>
</div> </div>
<div> <div>
<p className="font-medium text-gray-900">Add Grocery</p> <p className="font-medium text-gray-900">Add Product</p>
<p className="text-sm text-gray-600">Add a new grocery item</p> <p className="text-sm text-gray-600">Add a new product item</p>
</div> </div>
</button> </button>
@ -181,9 +181,9 @@ const Dashboard: React.FC = () => {
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
{new Date(event.date).toLocaleDateString()} {new Date(event.date).toLocaleDateString()}
</p> </p>
{event.groceries.length > 0 && ( {event.products.length > 0 && (
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
{event.groceries.length} item{event.groceries.length !== 1 ? 's' : ''} {event.products.length} item{event.products.length !== 1 ? 's' : ''}
</p> </p>
)} )}
</div> </div>

View File

@ -1,22 +1,22 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Grocery } from '../types'; import { Product } from '../types';
import { groceryApi } from '../services/api'; import { productApi } from '../services/api';
import AddGroceryModal from './AddGroceryModal'; import AddProductModal from './AddProductModal';
import ConfirmDeleteModal from './ConfirmDeleteModal'; import ConfirmDeleteModal from './ConfirmDeleteModal';
const GroceryList: React.FC = () => { const ProductList: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [groceries, setGroceries] = useState<Grocery[]>([]); const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingGrocery, setEditingGrocery] = useState<Grocery | null>(null); const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [deletingGrocery, setDeletingGrocery] = useState<Grocery | null>(null); const [deletingProduct, setDeletingProduct] = useState<Product | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false);
useEffect(() => { useEffect(() => {
fetchGroceries(); fetchProducts();
// Check if we should auto-open the modal // Check if we should auto-open the modal
if (searchParams.get('add') === 'true') { if (searchParams.get('add') === 'true') {
@ -26,55 +26,55 @@ const GroceryList: React.FC = () => {
} }
}, [searchParams, setSearchParams]); }, [searchParams, setSearchParams]);
const fetchGroceries = async () => { const fetchProducts = async () => {
try { try {
setLoading(true); setLoading(true);
const response = await groceryApi.getAll(); const response = await productApi.getAll();
setGroceries(response.data); setProducts(response.data);
} catch (err) { } catch (err) {
setError('Failed to fetch groceries'); setError('Failed to fetch products');
console.error('Error fetching groceries:', err); console.error('Error fetching products:', err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleEdit = (grocery: Grocery) => { const handleEdit = (product: Product) => {
setEditingGrocery(grocery); setEditingProduct(product);
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleDelete = (grocery: Grocery) => { const handleDelete = (product: Product) => {
setDeletingGrocery(grocery); setDeletingProduct(product);
}; };
const confirmDelete = async () => { const confirmDelete = async () => {
if (!deletingGrocery) return; if (!deletingProduct) return;
try { try {
setDeleteLoading(true); setDeleteLoading(true);
await groceryApi.delete(deletingGrocery.id); await productApi.delete(deletingProduct.id);
setDeletingGrocery(null); setDeletingProduct(null);
fetchGroceries(); // Refresh the list fetchProducts(); // Refresh the list
} catch (err) { } catch (err) {
console.error('Error deleting grocery:', err); console.error('Error deleting product:', err);
setError('Failed to delete grocery. Please try again.'); setError('Failed to delete product. Please try again.');
} finally { } finally {
setDeleteLoading(false); setDeleteLoading(false);
} }
}; };
const handleGroceryAdded = () => { const handleProductAdded = () => {
fetchGroceries(); // Refresh the list fetchProducts(); // Refresh the list
}; };
const handleCloseModal = () => { const handleCloseModal = () => {
setIsModalOpen(false); setIsModalOpen(false);
setEditingGrocery(null); setEditingProduct(null);
}; };
const handleCloseDeleteModal = () => { const handleCloseDeleteModal = () => {
setDeletingGrocery(null); setDeletingProduct(null);
}; };
if (loading) { if (loading) {
@ -88,15 +88,15 @@ const GroceryList: React.FC = () => {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Groceries</h1> <h1 className="text-2xl font-bold text-gray-900">Products</h1>
<button <button
onClick={() => { onClick={() => {
setEditingGrocery(null); setEditingProduct(null);
setIsModalOpen(true); setIsModalOpen(true);
}} }}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
> >
Add New Grocery Add New Product
</button> </button>
</div> </div>
@ -107,13 +107,13 @@ const GroceryList: React.FC = () => {
)} )}
<div className="bg-white shadow rounded-lg overflow-hidden"> <div className="bg-white shadow rounded-lg overflow-hidden">
{groceries.length === 0 ? ( {products.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48"> <svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg> </svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No groceries</h3> <h3 className="mt-2 text-sm font-medium text-gray-900">No products</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first grocery item.</p> <p className="mt-1 text-sm text-gray-500">Get started by adding your first product item.</p>
</div> </div>
) : ( ) : (
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
@ -137,39 +137,39 @@ const GroceryList: React.FC = () => {
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{groceries.map((grocery) => ( {products.map((product) => (
<tr key={grocery.id} className="hover:bg-gray-50"> <tr key={product.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">
{grocery.name} {grocery.organic ? '🌱' : ''} {product.name} {product.organic ? '🌱' : ''}
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800"> <span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
{grocery.category} {product.category}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : '-'} {product.weight ? `${product.weight}${product.weight_unit}` : '-'}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${ <span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
grocery.organic product.organic
? 'bg-green-100 text-green-800' ? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800' : 'bg-gray-100 text-gray-800'
}`}> }`}>
{grocery.organic ? 'Organic' : 'Conventional'} {product.organic ? 'Organic' : 'Conventional'}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button <button
onClick={() => handleEdit(grocery)} onClick={() => handleEdit(product)}
className="text-indigo-600 hover:text-indigo-900 mr-3" className="text-indigo-600 hover:text-indigo-900 mr-3"
> >
Edit Edit
</button> </button>
<button <button
onClick={() => handleDelete(grocery)} onClick={() => handleDelete(product)}
className="text-red-600 hover:text-red-900" className="text-red-600 hover:text-red-900"
> >
Delete Delete
@ -182,23 +182,23 @@ const GroceryList: React.FC = () => {
)} )}
</div> </div>
<AddGroceryModal <AddProductModal
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={handleCloseModal} onClose={handleCloseModal}
onGroceryAdded={handleGroceryAdded} onProductAdded={handleProductAdded}
editGrocery={editingGrocery} editProduct={editingProduct}
/> />
<ConfirmDeleteModal <ConfirmDeleteModal
isOpen={!!deletingGrocery} isOpen={!!deletingProduct}
onClose={handleCloseDeleteModal} onClose={handleCloseDeleteModal}
onConfirm={confirmDelete} onConfirm={confirmDelete}
title="Delete Grocery" title="Delete Product"
message={`Are you sure you want to delete "${deletingGrocery?.name}"? This action cannot be undone.`} message={`Are you sure you want to delete "${deletingProduct?.name}"? This action cannot be undone.`}
isLoading={deleteLoading} isLoading={deleteLoading}
/> />
</div> </div>
); );
}; };
export default GroceryList; export default ProductList;

View File

@ -1,13 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { Shop, Grocery, ShoppingEventCreate, GroceryInEvent } from '../types'; import { Shop, Product, ShoppingEventCreate, ProductInEvent } from '../types';
import { shopApi, groceryApi, shoppingEventApi } from '../services/api'; import { shopApi, productApi, shoppingEventApi } from '../services/api';
const ShoppingEventForm: React.FC = () => { const ShoppingEventForm: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [shops, setShops] = useState<Shop[]>([]); const [shops, setShops] = useState<Shop[]>([]);
const [groceries, setGroceries] = useState<Grocery[]>([]); const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingEvent, setLoadingEvent] = useState(false); const [loadingEvent, setLoadingEvent] = useState(false);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
@ -19,12 +19,12 @@ const ShoppingEventForm: React.FC = () => {
date: new Date().toISOString().split('T')[0], date: new Date().toISOString().split('T')[0],
total_amount: undefined, total_amount: undefined,
notes: '', notes: '',
groceries: [] products: []
}); });
const [selectedGroceries, setSelectedGroceries] = useState<GroceryInEvent[]>([]); const [selectedProducts, setSelectedProducts] = useState<ProductInEvent[]>([]);
const [newGroceryItem, setNewGroceryItem] = useState<GroceryInEvent>({ const [newProductItem, setNewProductItem] = useState<ProductInEvent>({
grocery_id: 0, product_id: 0,
amount: 1, amount: 1,
price: 0 price: 0
}); });
@ -32,28 +32,28 @@ const ShoppingEventForm: React.FC = () => {
useEffect(() => { useEffect(() => {
fetchShops(); fetchShops();
fetchGroceries(); fetchProducts();
if (isEditMode && id) { if (isEditMode && id) {
fetchShoppingEvent(parseInt(id)); fetchShoppingEvent(parseInt(id));
} }
}, [id, isEditMode]); }, [id, isEditMode]);
// Calculate total amount from selected groceries // Calculate total amount from selected products
const calculateTotal = (groceries: GroceryInEvent[]): number => { const calculateTotal = (products: ProductInEvent[]): number => {
const total = groceries.reduce((total, item) => total + (item.amount * item.price), 0); const total = products.reduce((total, item) => total + (item.amount * item.price), 0);
return Math.round(total * 100) / 100; // Round to 2 decimal places to avoid floating-point errors return Math.round(total * 100) / 100; // Round to 2 decimal places to avoid floating-point errors
}; };
// Update total amount whenever selectedGroceries changes // Update total amount whenever selectedProducts changes
useEffect(() => { useEffect(() => {
if (autoCalculate) { if (autoCalculate) {
const calculatedTotal = calculateTotal(selectedGroceries); const calculatedTotal = calculateTotal(selectedProducts);
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
total_amount: calculatedTotal > 0 ? calculatedTotal : undefined total_amount: calculatedTotal > 0 ? calculatedTotal : undefined
})); }));
} }
}, [selectedGroceries, autoCalculate]); }, [selectedProducts, autoCalculate]);
const fetchShops = async () => { const fetchShops = async () => {
try { try {
@ -64,12 +64,12 @@ const ShoppingEventForm: React.FC = () => {
} }
}; };
const fetchGroceries = async () => { const fetchProducts = async () => {
try { try {
const response = await groceryApi.getAll(); const response = await productApi.getAll();
setGroceries(response.data); setProducts(response.data);
} catch (error) { } catch (error) {
console.error('Error fetching groceries:', error); console.error('Error fetching products:', error);
} }
}; };
@ -86,15 +86,15 @@ const ShoppingEventForm: React.FC = () => {
formattedDate = event.date.split('T')[0]; formattedDate = event.date.split('T')[0];
} }
// Map groceries to the format we need // Map products to the format we need
const mappedGroceries = event.groceries.map(g => ({ const mappedProducts = event.products.map(p => ({
grocery_id: g.id, product_id: p.id,
amount: g.amount, amount: p.amount,
price: g.price price: p.price
})); }));
// Calculate the sum of all groceries // Calculate the sum of all products
const calculatedTotal = calculateTotal(mappedGroceries); const calculatedTotal = calculateTotal(mappedProducts);
// Check if existing total matches calculated total (with small tolerance for floating point) // Check if existing total matches calculated total (with small tolerance for floating point)
const existingTotal = event.total_amount || 0; const existingTotal = event.total_amount || 0;
@ -105,10 +105,10 @@ const ShoppingEventForm: React.FC = () => {
date: formattedDate, date: formattedDate,
total_amount: event.total_amount, total_amount: event.total_amount,
notes: event.notes || '', notes: event.notes || '',
groceries: [] products: []
}); });
setSelectedGroceries(mappedGroceries); setSelectedProducts(mappedProducts);
setAutoCalculate(totalMatches); // Enable auto-calc if totals match, disable if they don't setAutoCalculate(totalMatches); // Enable auto-calc if totals match, disable if they don't
} catch (error) { } catch (error) {
console.error('Error fetching shopping event:', error); console.error('Error fetching shopping event:', error);
@ -118,27 +118,27 @@ const ShoppingEventForm: React.FC = () => {
} }
}; };
const addGroceryToEvent = () => { const addProductToEvent = () => {
if (newGroceryItem.grocery_id > 0 && newGroceryItem.amount > 0 && newGroceryItem.price >= 0) { if (newProductItem.product_id > 0 && newProductItem.amount > 0 && newProductItem.price >= 0) {
setSelectedGroceries([...selectedGroceries, { ...newGroceryItem }]); setSelectedProducts([...selectedProducts, { ...newProductItem }]);
setNewGroceryItem({ grocery_id: 0, amount: 1, price: 0 }); setNewProductItem({ product_id: 0, amount: 1, price: 0 });
} }
}; };
const removeGroceryFromEvent = (index: number) => { const removeProductFromEvent = (index: number) => {
setSelectedGroceries(selectedGroceries.filter((_, i) => i !== index)); setSelectedProducts(selectedProducts.filter((_, i) => i !== index));
}; };
const editGroceryFromEvent = (index: number) => { const editProductFromEvent = (index: number) => {
const groceryToEdit = selectedGroceries[index]; const productToEdit = selectedProducts[index];
// Load the grocery data into the input fields // Load the product data into the input fields
setNewGroceryItem({ setNewProductItem({
grocery_id: groceryToEdit.grocery_id, product_id: productToEdit.product_id,
amount: groceryToEdit.amount, amount: productToEdit.amount,
price: groceryToEdit.price price: productToEdit.price
}); });
// Remove the item from the selected list // Remove the item from the selected list
setSelectedGroceries(selectedGroceries.filter((_, i) => i !== index)); setSelectedProducts(selectedProducts.filter((_, i) => i !== index));
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@ -149,7 +149,7 @@ const ShoppingEventForm: React.FC = () => {
try { try {
const eventData = { const eventData = {
...formData, ...formData,
groceries: selectedGroceries products: selectedProducts
}; };
if (isEditMode) { if (isEditMode) {
@ -173,9 +173,9 @@ const ShoppingEventForm: React.FC = () => {
date: new Date().toISOString().split('T')[0], date: new Date().toISOString().split('T')[0],
total_amount: undefined, total_amount: undefined,
notes: '', notes: '',
groceries: [] products: []
}); });
setSelectedGroceries([]); setSelectedProducts([]);
} }
} catch (error) { } catch (error) {
console.error('Full error object:', error); console.error('Full error object:', error);
@ -185,13 +185,13 @@ const ShoppingEventForm: React.FC = () => {
} }
}; };
const getGroceryName = (id: number) => { const getProductName = (id: number) => {
const grocery = groceries.find(g => g.id === id); const product = products.find(p => p.id === id);
if (!grocery) return 'Unknown'; if (!product) return 'Unknown';
const weightInfo = grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : grocery.weight_unit; const weightInfo = product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit;
const organicEmoji = grocery.organic ? ' 🌱' : ''; const organicEmoji = product.organic ? ' 🌱' : '';
return `${grocery.name}${organicEmoji} ${weightInfo}`; return `${product.name}${organicEmoji} ${weightInfo}`;
}; };
if (loadingEvent) { if (loadingEvent) {
@ -265,25 +265,25 @@ const ShoppingEventForm: React.FC = () => {
/> />
</div> </div>
{/* Add Groceries Section */} {/* Add Products Section */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Add Groceries Add Products
</label> </label>
<div className="flex space-x-2 mb-4"> <div className="flex space-x-2 mb-4">
<div className="flex-1"> <div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1"> <label className="block text-xs font-medium text-gray-700 mb-1">
Grocery Product
</label> </label>
<select <select
value={newGroceryItem.grocery_id} value={newProductItem.product_id}
onChange={(e) => setNewGroceryItem({...newGroceryItem, grocery_id: parseInt(e.target.value)})} onChange={(e) => setNewProductItem({...newProductItem, product_id: parseInt(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
> >
<option value={0}>Select a grocery</option> <option value={0}>Select a product</option>
{groceries.map(grocery => ( {products.map(product => (
<option key={grocery.id} value={grocery.id}> <option key={product.id} value={product.id}>
{grocery.name}{grocery.organic ? '🌱' : ''} ({grocery.category}) {grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : grocery.weight_unit} {product.name}{product.organic ? '🌱' : ''} ({product.category}) {product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit}
</option> </option>
))} ))}
</select> </select>
@ -297,8 +297,8 @@ const ShoppingEventForm: React.FC = () => {
step="1" step="1"
min="1" min="1"
placeholder="1" placeholder="1"
value={newGroceryItem.amount} value={newProductItem.amount}
onChange={(e) => setNewGroceryItem({...newGroceryItem, amount: parseFloat(e.target.value)})} onChange={(e) => setNewProductItem({...newProductItem, amount: parseFloat(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
</div> </div>
@ -311,15 +311,15 @@ const ShoppingEventForm: React.FC = () => {
step="0.01" step="0.01"
min="0" min="0"
placeholder="0.00" placeholder="0.00"
value={newGroceryItem.price} value={newProductItem.price}
onChange={(e) => setNewGroceryItem({...newGroceryItem, price: parseFloat(e.target.value)})} onChange={(e) => setNewProductItem({...newProductItem, price: parseFloat(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
</div> </div>
<div className="flex items-end"> <div className="flex items-end">
<button <button
type="button" type="button"
onClick={addGroceryToEvent} onClick={addProductToEvent}
className="bg-green-500 hover:bg-green-700 text-white px-4 py-2 rounded-md" className="bg-green-500 hover:bg-green-700 text-white px-4 py-2 rounded-md"
> >
Add Add
@ -327,15 +327,15 @@ const ShoppingEventForm: React.FC = () => {
</div> </div>
</div> </div>
{/* Selected Groceries List */} {/* Selected Products List */}
{selectedGroceries.length > 0 && ( {selectedProducts.length > 0 && (
<div className="bg-gray-50 rounded-md p-4"> <div className="bg-gray-50 rounded-md p-4">
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4> <h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
{selectedGroceries.map((item, index) => ( {selectedProducts.map((item, index) => (
<div key={index} className="flex justify-between items-center py-2 border-b last:border-b-0"> <div key={index} className="flex justify-between items-center py-2 border-b last:border-b-0">
<div className="flex-1"> <div className="flex-1">
<div className="text-sm text-gray-900"> <div className="text-sm text-gray-900">
{getGroceryName(item.grocery_id)} {getProductName(item.product_id)}
</div> </div>
<div className="text-xs text-gray-600"> <div className="text-xs text-gray-600">
{item.amount} × ${item.price.toFixed(2)} = ${(item.amount * item.price).toFixed(2)} {item.amount} × ${item.price.toFixed(2)} = ${(item.amount * item.price).toFixed(2)}
@ -344,14 +344,14 @@ const ShoppingEventForm: React.FC = () => {
<div className="flex space-x-2"> <div className="flex space-x-2">
<button <button
type="button" type="button"
onClick={() => editGroceryFromEvent(index)} onClick={() => editProductFromEvent(index)}
className="text-blue-500 hover:text-blue-700" className="text-blue-500 hover:text-blue-700"
> >
Edit Edit
</button> </button>
<button <button
type="button" type="button"
onClick={() => removeGroceryFromEvent(index)} onClick={() => removeProductFromEvent(index)}
className="text-red-500 hover:text-red-700" className="text-red-500 hover:text-red-700"
> >
Remove Remove
@ -431,7 +431,7 @@ const ShoppingEventForm: React.FC = () => {
)} )}
<button <button
type="submit" type="submit"
disabled={loading || formData.shop_id === 0 || selectedGroceries.length === 0} disabled={loading || formData.shop_id === 0 || selectedProducts.length === 0}
className={`px-4 py-2 text-sm font-medium text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed ${ className={`px-4 py-2 text-sm font-medium text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed ${
isEditMode isEditMode
? 'bg-blue-600 hover:bg-blue-700' ? 'bg-blue-600 hover:bg-blue-700'

View File

@ -109,17 +109,17 @@ const ShoppingEventList: React.FC = () => {
</div> </div>
</div> </div>
{event.groceries.length > 0 && ( {event.products.length > 0 && (
<div className="mb-4"> <div className="mb-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Items Purchased:</h4> <h4 className="text-sm font-medium text-gray-700 mb-2">Items Purchased:</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{event.groceries.map((grocery) => ( {event.products.map((product) => (
<div key={grocery.id} className="bg-gray-50 rounded px-3 py-2"> <div key={product.id} className="bg-gray-50 rounded px-3 py-2">
<div className="text-sm text-gray-900"> <div className="text-sm text-gray-900">
{grocery.name} {grocery.organic ? '🌱' : ''} {product.name} {product.organic ? '🌱' : ''}
</div> </div>
<div className="text-xs text-gray-600"> <div className="text-xs text-gray-600">
{grocery.amount} × ${grocery.price.toFixed(2)} = ${(grocery.amount * grocery.price).toFixed(2)} {product.amount} × ${product.price.toFixed(2)} = ${(product.amount * product.price).toFixed(2)}
</div> </div>
</div> </div>
))} ))}

View File

@ -1,23 +1,34 @@
import axios from 'axios'; import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate } from '../types';
import { Grocery, GroceryCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate } from '../types';
const BASE_URL = 'http://localhost:8000'; const API_BASE_URL = 'http://localhost:8000';
const api = axios.create({ const api = {
baseURL: BASE_URL, get: <T>(url: string): Promise<{ data: T }> =>
headers: { fetch(`${API_BASE_URL}${url}`).then(res => res.json()).then(data => ({ data })),
'Content-Type': 'application/json', post: <T>(url: string, body: any): Promise<{ data: T }> =>
}, fetch(`${API_BASE_URL}${url}`, {
}); method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(res => res.json()).then(data => ({ data })),
put: <T>(url: string, body: any): Promise<{ data: T }> =>
fetch(`${API_BASE_URL}${url}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(res => res.json()).then(data => ({ data })),
delete: (url: string): Promise<void> =>
fetch(`${API_BASE_URL}${url}`, { method: 'DELETE' }).then(() => {}),
};
// Grocery API functions // Product API functions
export const groceryApi = { export const productApi = {
getAll: () => api.get<Grocery[]>('/groceries/'), getAll: () => api.get<Product[]>('/products/'),
getById: (id: number) => api.get<Grocery>(`/groceries/${id}`), getById: (id: number) => api.get<Product>(`/products/${id}`),
create: (grocery: GroceryCreate) => api.post<Grocery>('/groceries/', grocery), create: (product: ProductCreate) => api.post<Product>('/products/', product),
update: (id: number, grocery: Partial<GroceryCreate>) => update: (id: number, product: Partial<ProductCreate>) =>
api.put<Grocery>(`/groceries/${id}`, grocery), api.put<Product>(`/products/${id}`, product),
delete: (id: number) => api.delete(`/groceries/${id}`), delete: (id: number) => api.delete(`/products/${id}`),
}; };
// Shop API functions // Shop API functions
@ -25,7 +36,7 @@ export const shopApi = {
getAll: () => api.get<Shop[]>('/shops/'), getAll: () => api.get<Shop[]>('/shops/'),
getById: (id: number) => api.get<Shop>(`/shops/${id}`), getById: (id: number) => api.get<Shop>(`/shops/${id}`),
create: (shop: ShopCreate) => api.post<Shop>('/shops/', shop), create: (shop: ShopCreate) => api.post<Shop>('/shops/', shop),
update: (id: number, shop: Partial<ShopCreate>) => update: (id: number, shop: Partial<ShopCreate>) =>
api.put<Shop>(`/shops/${id}`, shop), api.put<Shop>(`/shops/${id}`, shop),
delete: (id: number) => api.delete(`/shops/${id}`), delete: (id: number) => api.delete(`/shops/${id}`),
}; };
@ -34,9 +45,8 @@ export const shopApi = {
export const shoppingEventApi = { export const shoppingEventApi = {
getAll: () => api.get<ShoppingEvent[]>('/shopping-events/'), getAll: () => api.get<ShoppingEvent[]>('/shopping-events/'),
getById: (id: number) => api.get<ShoppingEvent>(`/shopping-events/${id}`), getById: (id: number) => api.get<ShoppingEvent>(`/shopping-events/${id}`),
create: (event: ShoppingEventCreate) => create: (event: ShoppingEventCreate) => api.post<ShoppingEvent>('/shopping-events/', event),
api.post<ShoppingEvent>('/shopping-events/', event), update: (id: number, event: ShoppingEventCreate) =>
update: (id: number, event: ShoppingEventCreate) =>
api.put<ShoppingEvent>(`/shopping-events/${id}`, event), api.put<ShoppingEvent>(`/shopping-events/${id}`, event),
delete: (id: number) => api.delete(`/shopping-events/${id}`), delete: (id: number) => api.delete(`/shopping-events/${id}`),
}; };

View File

@ -1,4 +1,4 @@
export interface Grocery { export interface Product {
id: number; id: number;
name: string; name: string;
category: string; category: string;
@ -9,7 +9,7 @@ export interface Grocery {
updated_at?: string; updated_at?: string;
} }
export interface GroceryCreate { export interface ProductCreate {
name: string; name: string;
category: string; category: string;
organic: boolean; organic: boolean;
@ -32,13 +32,13 @@ export interface ShopCreate {
address?: string | null; address?: string | null;
} }
export interface GroceryInEvent { export interface ProductInEvent {
grocery_id: number; product_id: number;
amount: number; amount: number;
price: number; price: number;
} }
export interface GroceryWithEventData { export interface ProductWithEventData {
id: number; id: number;
name: string; name: string;
category: string; category: string;
@ -58,7 +58,7 @@ export interface ShoppingEvent {
created_at: string; created_at: string;
updated_at?: string; updated_at?: string;
shop: Shop; shop: Shop;
groceries: GroceryWithEventData[]; products: ProductWithEventData[];
} }
export interface ShoppingEventCreate { export interface ShoppingEventCreate {
@ -66,7 +66,7 @@ export interface ShoppingEventCreate {
date?: string; date?: string;
total_amount?: number; total_amount?: number;
notes?: string; notes?: string;
groceries: GroceryInEvent[]; products: ProductInEvent[];
} }
export interface CategoryStats { export interface CategoryStats {

View File

@ -1,15 +1,21 @@
{ {
"name": "grocery-tracker-frontend", "name": "product-tracker-frontend",
"version": "1.0.0", "version": "0.1.0",
"description": "React frontend for grocery price tracking application", "description": "React frontend for product price tracking application",
"private": true, "main": "index.js",
"scripts": { "scripts": {
"dev": "cd frontend && npm run dev", "dev": "cd frontend && npm run dev",
"build": "cd frontend && npm run build", "build": "cd frontend && npm run build",
"install:frontend": "cd frontend && npm install", "install:frontend": "cd frontend && npm install",
"setup": "npm run install:frontend" "setup": "npm run install:frontend",
"test": "echo \"Error: no test specified\" && exit 1"
}, },
"keywords": ["grocery", "price-tracking", "shopping", "react", "fastapi", "python"], "keywords": ["product", "price-tracking", "shopping", "react", "fastapi", "python"],
"author": "", "author": "",
"license": "MIT" "license": "ISC",
"dependencies": {},
"repository": {
"type": "git",
"url": ""
}
} }