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
Get your Grocery Tracker up and running in minutes!
Get your Product Tracker up and running in minutes!
## Prerequisites
@ -62,8 +62,8 @@ This will:
## First Steps
1. **Add a Shop**: Go to "Shops" and add your first grocery store
2. **Add Groceries**: Go to "Groceries" and add some items
1. **Add a Shop**: Go to "Shops" and add your first store
2. **Add Products**: Go to "Products" and add some items
3. **Record a Purchase**: Use "Add Purchase" to record your shopping
## Troubleshooting
@ -85,8 +85,8 @@ This will:
**Complete Backend**: FastAPI with SQLAlchemy and SQLite
**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
**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
- **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
- **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
- **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
#### Groceries (`groceries` table)
#### Products (`products` table)
- `id`: Integer, Primary key, Auto-increment
- `name`: String, Grocery name (indexed, required)
- `name`: String, Product name (indexed, required)
- `category`: String, Food category (required)
- `organic`: Boolean, Organic flag (default: false)
- `weight`: Float, Weight/volume (optional)
@ -72,11 +72,11 @@ A web application for tracking grocery prices and shopping events. Built with Fa
### Association Table
#### Shopping Event Groceries (`shopping_event_groceries` table)
Many-to-many relationship between shopping events and groceries with additional data:
#### Shopping Event Products (`shopping_event_products` table)
Many-to-many relationship between shopping events and products with additional data:
- `id`: Integer, Primary key, Auto-increment
- `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)
- `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) │ │ │
│ • id │ ←──────→│ • shopping_event_id │ ←──────→│ • id │
│ • name │ 1:N │ • grocery_id │ N:M │ • name │
│ • name │ 1:N │ • product_id │ N:M │ • name │
│ • city │ │ • amount │ │ • category │
│ • address │ │ • price │ │ • organic │
│ • created_at │ │ │ │ • weight │
@ -110,7 +110,7 @@ Many-to-many relationship between shopping events and groceries with additional
### 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)
- **Auto-calculation**: Total amount can be automatically calculated from individual items
- **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:**
```bash
# Create PostgreSQL database
createdb grocery_tracker
createdb product_tracker
# Copy environment variables
cp env.example .env
@ -189,12 +189,12 @@ Many-to-many relationship between shopping events and groceries with additional
## API Endpoints
### Groceries
- `GET /groceries/` - List all groceries
- `POST /groceries/` - Create new grocery
- `GET /groceries/{id}` - Get specific grocery
- `PUT /groceries/{id}` - Update grocery
- `DELETE /groceries/{id}` - Delete grocery
### Products
- `GET /products/` - List all products
- `POST /products/` - Create new product
- `GET /products/{id}` - Get specific product
- `PUT /products/{id}` - Update product
- `DELETE /products/{id}` - Delete product
### Shops
- `GET /shops/` - List all shops
@ -212,8 +212,8 @@ Many-to-many relationship between shopping events and groceries with additional
## Usage
1. **Add Shops**: Start by adding shops where you buy groceries
2. **Add Groceries**: Create grocery items with prices and categories
1. **Add Shops**: Start by adding shops where you buy products
2. **Add Products**: Create product items with prices and categories
3. **Record Purchases**: Use the "Add Purchase" form to record shopping events
4. **Track Prices**: Monitor how prices change over time
5. **View Statistics**: Analyze spending patterns by category and shop
@ -252,8 +252,8 @@ services:
db:
image: postgres:15
environment:
POSTGRES_DB: grocery_tracker
POSTGRES_USER: grocery_user
POSTGRES_DB: product_tracker
POSTGRES_USER: product_user
POSTGRES_PASSWORD: your_password
volumes:
- postgres_data:/var/lib/postgresql/data
@ -267,7 +267,7 @@ services:
depends_on:
- db
environment:
DATABASE_URL: postgresql://grocery_user:your_password@db:5432/grocery_tracker
DATABASE_URL: postgresql://product_user:your_password@db:5432/product_tracker
frontend:
build: ./frontend

View File

@ -1,12 +1,12 @@
# 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
### 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
@ -32,7 +32,7 @@ python create_test_data.py --dry-run
| `--days N` | Number of days back to generate events | 90 |
| `--url URL` | API base URL | http://localhost:8000 |
| `--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 |
| `--verbose`, `-v` | Verbose output with detailed progress | 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
python create_test_data.py --shops-only
# Create only groceries
python create_test_data.py --groceries-only
# Create only products
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
# Create test data for the past 6 months with verbose output
@ -74,14 +74,14 @@ python cleanup_test_data.py
- **Safeway** (San Francisco)
- **Trader Joe's** (Berkeley)
- **Berkeley Bowl** (Berkeley)
- **Rainbow Grocery** (San Francisco)
- **Rainbow Product** (San Francisco)
- **Mollie Stone's Market** (Palo Alto)
- **Costco Wholesale** (San Mateo)
- **Target** (Mountain View)
- **Sprouts Farmers Market** (Sunnyvale)
- **Lucky Supermarket** (San Jose)
### Groceries (50+ items across 8 categories)
### Products (50+ items across 8 categories)
| Category | Items | Organic Options |
|----------|-------|-----------------|
@ -190,7 +190,7 @@ After running the test data scripts:
The test data is designed to showcase all application features:
- Multiple shops and locations
- Diverse grocery categories
- Diverse product categories
- Realistic shopping patterns
- Price variations and organic options
- 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:
- Add more shops for specific regions
- Include specialty grocery categories
- Include specialty product categories
- Adjust price ranges for different markets
- Create seasonal shopping patterns

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
Script to clean up all test data from the Grocery Tracker application.
This will delete all shopping events, groceries, and shops.
Script to clean up all test data from the Product Tracker application.
This will delete all shopping events, products, and shops.
"""
import requests
@ -42,37 +42,37 @@ def delete_all_shopping_events() -> int:
print(f" ❌ Error fetching shopping events: {e}")
return 0
def delete_all_groceries() -> int:
"""Delete all groceries and return the count of deleted groceries."""
print("🥬 Deleting all groceries...")
def delete_all_products() -> int:
"""Delete all products and return the count of deleted products."""
print("🥬 Deleting all products...")
try:
# Get all groceries
response = requests.get(f"{BASE_URL}/groceries/")
# Get all products
response = requests.get(f"{BASE_URL}/products/")
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
groceries = response.json()
products = response.json()
deleted_count = 0
for grocery in groceries:
for product in products:
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:
deleted_count += 1
organic_label = "🌱" if grocery['organic'] else "🌾"
print(f" ✅ Deleted grocery: {organic_label} {grocery['name']}")
organic_label = "🌱" if product['organic'] else "🌾"
print(f" ✅ Deleted product: {organic_label} {product['name']}")
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:
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
except Exception as e:
print(f" ❌ Error fetching groceries: {e}")
print(f" ❌ Error fetching products: {e}")
return 0
def delete_all_shops() -> int:
@ -115,15 +115,15 @@ def get_current_data_summary():
try:
# Get counts
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/")
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
print(f"🏪 Shops: {shops_count}")
print(f"🥬 Groceries: {groceries_count}")
print(f"🥬 Products: {products_count}")
print(f"🛒 Shopping Events: {events_count}")
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()
return shops_count, groceries_count, events_count
return shops_count, products_count, events_count
except Exception as e:
print(f"❌ Error getting data summary: {e}")
@ -140,9 +140,9 @@ def get_current_data_summary():
def main():
"""Main function to clean up all test data."""
print("🧹 GROCERY TRACKER DATA CLEANUP")
print("🧹 PRODUCT TRACKER DATA CLEANUP")
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")
try:
@ -155,9 +155,9 @@ def main():
print("✅ Connected to API server\n")
# 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!")
return
@ -171,19 +171,19 @@ def main():
print("\n🧹 Starting cleanup process...\n")
# Delete in order: events -> groceries -> shops
# Delete in order: events -> products -> shops
# (due to foreign key constraints)
deleted_events = delete_all_shopping_events()
deleted_groceries = delete_all_groceries()
deleted_products = delete_all_products()
deleted_shops = delete_all_shops()
# Final summary
print("📋 CLEANUP SUMMARY")
print("=" * 30)
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"📊 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("The database is now empty and ready for fresh data.")

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
Script to create comprehensive test data for the Grocery Tracker application.
This includes shops, groceries, and shopping events with realistic data.
Script to create comprehensive test data for the Product Tracker application.
This includes shops, products, and shopping events with realistic data.
"""
import requests
@ -20,7 +20,7 @@ SHOPS_DATA = [
{"name": "Safeway", "city": "San Francisco", "address": "2020 Market St"},
{"name": "Trader Joe's", "city": "Berkeley", "address": "1885 University Ave"},
{"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": "Costco Wholesale", "city": "San Mateo", "address": "2300 S Norfolk St"},
{"name": "Target", "city": "Mountain View", "address": "1200 El Camino Real"},
@ -119,13 +119,13 @@ PRICE_RANGES = {
def parse_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('--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('--shops-only', action='store_true', help='Create only shops')
parser.add_argument('--groceries-only', action='store_true', help='Create only groceries')
parser.add_argument('--events-only', action='store_true', help='Create only shopping events (requires existing shops and 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 products)')
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')
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")
return created_shops
def create_groceries(base_url: str, verbose: bool = False, dry_run: bool = False) -> List[Dict[str, Any]]:
"""Create groceries and return the created grocery objects."""
print("🥬 Creating groceries...")
created_groceries = []
def create_products(base_url: str, verbose: bool = False, dry_run: bool = False) -> List[Dict[str, Any]]:
"""Create products and return the created product objects."""
print("🥬 Creating products...")
created_products = []
if dry_run:
print(" [DRY RUN] Would create the following groceries:")
for grocery_data in GROCERIES_DATA:
organic_label = "🌱" if grocery_data['organic'] else "🌾"
print(f" 📋 {organic_label} {grocery_data['name']} ({grocery_data['category']})")
print(" [DRY RUN] Would create the following products:")
for product_data in GROCERIES_DATA:
organic_label = "🌱" if product_data['organic'] else "🌾"
print(f" 📋 {organic_label} {product_data['name']} ({product_data['category']})")
return []
for grocery_data in GROCERIES_DATA:
for product_data in GROCERIES_DATA:
try:
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:
grocery = response.json()
created_groceries.append(grocery)
organic_label = "🌱" if grocery['organic'] else "🌾"
print(f" ✅ Created grocery: {organic_label} {grocery['name']} ({grocery['category']})")
product = response.json()
created_products.append(product)
organic_label = "🌱" if product['organic'] else "🌾"
print(f" ✅ Created product: {organic_label} {product['name']} ({product['category']})")
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:
print(f" Response: {response.text}")
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:
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")
return created_groceries
print(f" 📊 Created {len(created_products)} products total\n")
return created_products
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))
# 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)
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:
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 []
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:
print(f" ❌ Error fetching existing data: {e}")
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,
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...")
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.")
return []
if not groceries:
print(" ❌ No groceries available. Cannot create shopping events.")
if not products:
print(" ❌ No products available. Cannot create shopping events.")
return []
# 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" 📋 Date range: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
print(f" 📋 Available shops: {len(shops)}")
print(f" 📋 Available groceries: {len(groceries)}")
print(f" 📋 Available products: {len(products)}")
return []
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)
)
# Random number of groceries (2-8 items per shopping trip)
num_groceries = random.randint(2, 8)
selected_groceries = random.sample(groceries, min(num_groceries, len(groceries)))
# Random number of products (2-8 items per shopping trip)
num_products = random.randint(2, 8)
selected_products = random.sample(products, min(num_products, len(products)))
# Create grocery items for this event
event_groceries = []
# Create product items for this event
event_products = []
total_amount = 0.0
for grocery in selected_groceries:
for product in selected_products:
# Random amount based on item type
if grocery['weight_unit'] == 'piece':
if product['weight_unit'] == 'piece':
amount = random.randint(1, 4)
elif grocery['weight_unit'] == 'dozen':
elif product['weight_unit'] == 'dozen':
amount = 1
elif grocery['weight_unit'] in ['box', 'head', 'bunch']:
elif product['weight_unit'] in ['box', 'head', 'bunch']:
amount = random.randint(1, 2)
elif grocery['weight_unit'] in ['gallon', 'l']:
elif product['weight_unit'] in ['gallon', 'l']:
amount = 1
else:
amount = round(random.uniform(0.5, 3.0), 2)
# 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({
"grocery_id": grocery['id'],
event_products.append({
"product_id": product['id'],
"amount": amount,
"price": price
})
@ -311,11 +311,11 @@ def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: s
notes = None
if random.random() < 0.3:
note_options = [
"Weekly grocery shopping",
"Weekly product shopping",
"Quick lunch ingredients",
"Dinner party prep",
"Meal prep for the week",
"Emergency grocery run",
"Emergency product run",
"Organic produce haul",
"Bulk shopping trip",
"Special occasion shopping",
@ -332,14 +332,14 @@ def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: s
"date": event_date.isoformat(),
"total_amount": total_amount,
"notes": notes,
"groceries": event_groceries
"products": event_products
}
response = requests.post(f"{base_url}/shopping-events/", json=event_data, timeout=15)
if response.status_code == 200:
event = response.json()
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:
print(f" ❌ Failed to create shopping event: {response.status_code}")
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")
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("📋 TEST DATA SUMMARY")
print("=" * 50)
@ -361,13 +361,13 @@ def print_summary(shops: List[Dict], groceries: List[Dict], events: List[Dict]):
for shop in shops:
print(f"{shop['name']} ({shop['city']})")
print(f"\n🥬 Groceries: {len(groceries)}")
print(f"\n🥬 Products: {len(products)}")
categories = {}
for grocery in groceries:
category = grocery['category']
for product in products:
category = product['category']
if category not in categories:
categories[category] = []
categories[category].append(grocery)
categories[category].append(product)
for category, items in categories.items():
organic_count = sum(1 for item in items if item['organic'])
@ -394,7 +394,7 @@ def main():
"""Main function to create all test data."""
args = parse_arguments()
print("🚀 GROCERY TRACKER TEST DATA GENERATOR")
print("🚀 PRODUCT TRACKER TEST DATA GENERATOR")
print("=" * 50)
if args.dry_run:
@ -415,28 +415,28 @@ def main():
print("✅ Connected to API server\n")
shops = []
groceries = []
products = []
events = []
# Create data based on arguments
if args.shops_only:
shops = create_shops(args.url, args.verbose, args.dry_run)
elif args.groceries_only:
groceries = create_groceries(args.url, args.verbose, args.dry_run)
elif args.products_only:
products = create_products(args.url, args.verbose, args.dry_run)
elif args.events_only:
# Get existing data for events
shops, groceries = get_existing_data(args.url)
events = create_shopping_events(shops, groceries, args.url, args.events, args.days, args.verbose, args.dry_run)
shops, products = get_existing_data(args.url)
events = create_shopping_events(shops, products, args.url, args.events, args.days, args.verbose, args.dry_run)
else:
# Create all data
shops = create_shops(args.url, args.verbose, args.dry_run)
groceries = create_groceries(args.url, args.verbose, args.dry_run)
if shops and groceries:
events = create_shopping_events(shops, groceries, args.url, args.events, args.days, args.verbose, args.dry_run)
products = create_products(args.url, args.verbose, args.dry_run)
if shops and products:
events = create_shopping_events(shops, products, args.url, args.events, args.days, args.verbose, args.dry_run)
# Print summary
if not args.dry_run:
print_summary(shops, groceries, events)
print_summary(shops, products, events)
if args.dry_run:
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:
# 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")
else:
print(f"🐘 Using PostgreSQL database")

View File

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

View File

@ -6,19 +6,19 @@ from datetime import datetime
Base = declarative_base()
# Association table for many-to-many relationship between shopping events and groceries
shopping_event_groceries = Table(
'shopping_event_groceries',
# Association table for many-to-many relationship between shopping events and products
shopping_event_products = Table(
'shopping_event_products',
Base.metadata,
Column('id', Integer, primary_key=True, autoincrement=True), # Artificial primary key
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), nullable=False),
Column('grocery_id', Integer, ForeignKey('groceries.id'), nullable=False),
Column('amount', Float, nullable=False), # Amount of this grocery bought in this event
Column('price', Float, nullable=False) # Price of this grocery at the time of this shopping event
Column('product_id', Integer, ForeignKey('products.id'), nullable=False),
Column('amount', Float, nullable=False), # Amount of this product bought in this event
Column('price', Float, nullable=False) # Price of this product at the time of this shopping event
)
class Grocery(Base):
__tablename__ = "groceries"
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True, 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())
# 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):
__tablename__ = "shops"
@ -58,4 +58,4 @@ class ShoppingEvent(Base):
# Relationships
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
os.chdir(backend_dir)
print("🍃 Starting Grocery Tracker Backend Development Server")
print("🍃 Starting Product Tracker Backend Development Server")
print("=" * 50)
# Check if virtual environment exists

View File

@ -3,24 +3,24 @@ from typing import Optional, List
from datetime import datetime
# Base schemas
class GroceryBase(BaseModel):
class ProductBase(BaseModel):
name: str
category: str
organic: bool = False
weight: Optional[float] = None
weight_unit: str = "g"
class GroceryCreate(GroceryBase):
class ProductCreate(ProductBase):
pass
class GroceryUpdate(BaseModel):
class ProductUpdate(BaseModel):
name: Optional[str] = None
category: Optional[str] = None
organic: Optional[bool] = None
weight: Optional[float] = None
weight_unit: Optional[str] = None
class Grocery(GroceryBase):
class Product(ProductBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
@ -51,12 +51,12 @@ class Shop(ShopBase):
from_attributes = True
# Shopping Event schemas
class GroceryInEvent(BaseModel):
grocery_id: int
class ProductInEvent(BaseModel):
product_id: int
amount: float = Field(..., gt=0)
price: float = Field(..., ge=0) # Price at the time of this shopping event (allow free items)
class GroceryWithEventData(BaseModel):
class ProductWithEventData(BaseModel):
id: int
name: str
category: str
@ -76,21 +76,21 @@ class ShoppingEventBase(BaseModel):
notes: Optional[str] = None
class ShoppingEventCreate(ShoppingEventBase):
groceries: List[GroceryInEvent] = []
products: List[ProductInEvent] = []
class ShoppingEventUpdate(BaseModel):
shop_id: Optional[int] = None
date: Optional[datetime] = None
total_amount: Optional[float] = Field(None, ge=0)
notes: Optional[str] = None
groceries: Optional[List[GroceryInEvent]] = None
products: Optional[List[ProductInEvent]] = None
class ShoppingEventResponse(ShoppingEventBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
shop: Shop
groceries: List[GroceryWithEventData] = []
products: List[ProductWithEventData] = []
class Config:
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">
<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">
<root>
<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">
<mxGeometry x="650" y="280" width="40" height="30" as="geometry"/>
</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">
<mxPoint x="280" y="150" as="sourcePoint"/>
<mxPoint x="720" y="290" as="targetPoint"/>
@ -32,7 +32,7 @@
</Array>
</mxGeometry>
</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"/>
</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">
@ -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">
<mxGeometry y="90" width="300" height="20" as="geometry"/>
</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"/>
</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"/>
</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">
@ -335,7 +335,7 @@
<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_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"/>
</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">
@ -372,7 +372,7 @@
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</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">
<mxRectangle width="210" height="30" as="alternateBounds"/>
</mxGeometry>

View File

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

View File

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

View File

@ -7,9 +7,9 @@
<meta name="theme-color" content="#000000" />
<meta
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>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

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

View File

@ -1,67 +1,64 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Link } 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 { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
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() {
return (
<Router>
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
<nav className="bg-white shadow-lg">
<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">
<div className="min-h-screen bg-gray-100">
<Navigation />
<main className="container mx-auto py-8 px-4">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/groceries" element={<GroceryList />} />
<Route path="/products" element={<ProductList />} />
<Route path="/shops" element={<ShopList />} />
<Route path="/shopping-events" element={<ShoppingEventList />} />
<Route path="/shopping-events/:id/edit" element={<ShoppingEventForm />} />

View File

@ -1,15 +1,15 @@
import React, { useState, useEffect } from 'react';
import { groceryApi } from '../services/api';
import { Grocery } from '../types';
import { productApi } from '../services/api';
import { Product } from '../types';
interface AddGroceryModalProps {
interface AddProductModalProps {
isOpen: boolean;
onClose: () => void;
onGroceryAdded: () => void;
editGrocery?: Grocery | null;
onProductAdded: () => void;
editProduct?: Product | null;
}
interface GroceryFormData {
interface ProductFormData {
name: string;
category: string;
organic: boolean;
@ -17,8 +17,8 @@ interface GroceryFormData {
weight_unit: string;
}
const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGroceryAdded, editGrocery }) => {
const [formData, setFormData] = useState<GroceryFormData>({
const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct }) => {
const [formData, setFormData] = useState<ProductFormData>({
name: '',
category: '',
organic: false,
@ -37,16 +37,16 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
// Populate form when editing
useEffect(() => {
if (editGrocery) {
if (editProduct) {
setFormData({
name: editGrocery.name,
category: editGrocery.category,
organic: editGrocery.organic,
weight: editGrocery.weight,
weight_unit: editGrocery.weight_unit
name: editProduct.name,
category: editProduct.category,
organic: editProduct.organic,
weight: editProduct.weight,
weight_unit: editProduct.weight_unit
});
} else {
// Reset form for adding new grocery
// Reset form for adding new product
setFormData({
name: '',
category: '',
@ -56,7 +56,7 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
});
}
setError('');
}, [editGrocery, isOpen]);
}, [editProduct, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -69,17 +69,17 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
setLoading(true);
setError('');
const groceryData = {
const productData = {
...formData,
weight: formData.weight || undefined
};
if (editGrocery) {
// Update existing grocery
await groceryApi.update(editGrocery.id, groceryData);
if (editProduct) {
// Update existing product
await productApi.update(editProduct.id, productData);
} else {
// Create new grocery
await groceryApi.create(groceryData);
// Create new product
await productApi.create(productData);
}
// Reset form
@ -91,11 +91,11 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
weight_unit: 'piece'
});
onGroceryAdded();
onProductAdded();
onClose();
} catch (err) {
setError(`Failed to ${editGrocery ? 'update' : 'add'} grocery. Please try again.`);
console.error(`Error ${editGrocery ? 'updating' : 'adding'} grocery:`, err);
setError(`Failed to ${editProduct ? 'update' : 'add'} product. Please try again.`);
console.error(`Error ${editProduct ? 'updating' : 'adding'} product:`, err);
} finally {
setLoading(false);
}
@ -119,7 +119,7 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
<div className="mt-3">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">
{editGrocery ? 'Edit Grocery' : 'Add New Grocery'}
{editProduct ? 'Edit Product' : 'Add New Product'}
</h3>
<button
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"
>
{loading
? (editGrocery ? 'Updating...' : 'Adding...')
: (editGrocery ? 'Update Grocery' : 'Add Grocery')
? (editProduct ? 'Updating...' : 'Adding...')
: (editProduct ? 'Update Product' : 'Add Product')
}
</button>
</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>
<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 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="grid grid-cols-1 md:grid-cols-3 gap-4">
<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"
>
<div className="p-2 bg-blue-100 rounded-md mr-3">
@ -117,7 +117,7 @@ const Dashboard: React.FC = () => {
</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"
>
<div className="p-2 bg-green-100 rounded-md mr-3">
@ -126,8 +126,8 @@ const Dashboard: React.FC = () => {
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Add Grocery</p>
<p className="text-sm text-gray-600">Add a new grocery item</p>
<p className="font-medium text-gray-900">Add Product</p>
<p className="text-sm text-gray-600">Add a new product item</p>
</div>
</button>
@ -181,9 +181,9 @@ const Dashboard: React.FC = () => {
<p className="text-sm text-gray-600 mt-1">
{new Date(event.date).toLocaleDateString()}
</p>
{event.groceries.length > 0 && (
{event.products.length > 0 && (
<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>
)}
</div>

View File

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

View File

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

View File

@ -109,17 +109,17 @@ const ShoppingEventList: React.FC = () => {
</div>
</div>
{event.groceries.length > 0 && (
{event.products.length > 0 && (
<div className="mb-4">
<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">
{event.groceries.map((grocery) => (
<div key={grocery.id} className="bg-gray-50 rounded px-3 py-2">
{event.products.map((product) => (
<div key={product.id} className="bg-gray-50 rounded px-3 py-2">
<div className="text-sm text-gray-900">
{grocery.name} {grocery.organic ? '🌱' : ''}
{product.name} {product.organic ? '🌱' : ''}
</div>
<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>
))}

View File

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

View File

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

View File

@ -1,15 +1,21 @@
{
"name": "grocery-tracker-frontend",
"version": "1.0.0",
"description": "React frontend for grocery price tracking application",
"private": true,
"name": "product-tracker-frontend",
"version": "0.1.0",
"description": "React frontend for product price tracking application",
"main": "index.js",
"scripts": {
"dev": "cd frontend && npm run dev",
"build": "cd frontend && npm run build",
"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": "",
"license": "MIT"
"license": "ISC",
"dependencies": {},
"repository": {
"type": "git",
"url": ""
}
}