Compare commits
No commits in common. "28db52dc2e7814cfa5de3f0ae1820a81f52776c2" and "b81379432b417de1facf6385cb957b14dedc7083" have entirely different histories.
28db52dc2e
...
b81379432b
92
README.md
92
README.md
@ -40,80 +40,28 @@ A web application for tracking grocery prices and shopping events. Built with Fa
|
|||||||
|
|
||||||
## Data Model
|
## Data Model
|
||||||
|
|
||||||
### Core Entities
|
### Groceries
|
||||||
|
- `id`: Primary key
|
||||||
|
- `name`: Grocery name
|
||||||
|
- `price`: Current price
|
||||||
|
- `category`: Food category
|
||||||
|
- `organic`: Boolean flag
|
||||||
|
- `weight`: Weight/volume
|
||||||
|
- `weight_unit`: Unit (g, kg, ml, l, piece)
|
||||||
|
|
||||||
#### Groceries (`groceries` table)
|
### Shops
|
||||||
- `id`: Integer, Primary key, Auto-increment
|
- `id`: Primary key
|
||||||
- `name`: String, Grocery name (indexed, required)
|
- `name`: Shop name
|
||||||
- `category`: String, Food category (required)
|
- `city`: Location city
|
||||||
- `organic`: Boolean, Organic flag (default: false)
|
- `address`: Optional full address
|
||||||
- `weight`: Float, Weight/volume (optional)
|
|
||||||
- `weight_unit`: String, Unit of measurement (default: "piece")
|
|
||||||
- Supported units: "g", "kg", "ml", "l", "piece"
|
|
||||||
- `created_at`: DateTime, Creation timestamp (auto-generated)
|
|
||||||
- `updated_at`: DateTime, Last update timestamp (auto-updated)
|
|
||||||
|
|
||||||
#### Shops (`shops` table)
|
### Shopping Events
|
||||||
- `id`: Integer, Primary key, Auto-increment
|
- `id`: Primary key
|
||||||
- `name`: String, Shop name (indexed, required)
|
- `shop_id`: Foreign key to shops
|
||||||
- `city`: String, Location city (required)
|
- `date`: Purchase date
|
||||||
- `address`: String, Full address (optional)
|
- `total_amount`: Optional total cost
|
||||||
- `created_at`: DateTime, Creation timestamp (auto-generated)
|
- `notes`: Optional notes
|
||||||
|
- `groceries`: Many-to-many relationship with amounts
|
||||||
#### Shopping Events (`shopping_events` table)
|
|
||||||
- `id`: Integer, Primary key, Auto-increment
|
|
||||||
- `shop_id`: Integer, Foreign key to shops (required)
|
|
||||||
- `date`: DateTime, Purchase date (required, default: current time)
|
|
||||||
- `total_amount`: Float, Total cost of shopping event (optional, auto-calculated)
|
|
||||||
- `notes`: String, Optional notes about the purchase
|
|
||||||
- `created_at`: DateTime, Creation timestamp (auto-generated)
|
|
||||||
|
|
||||||
### Association Table
|
|
||||||
|
|
||||||
#### Shopping Event Groceries (`shopping_event_groceries` table)
|
|
||||||
Many-to-many relationship between shopping events and groceries 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)
|
|
||||||
- `amount`: Float, Quantity purchased in this event (required, > 0)
|
|
||||||
- `price`: Float, Price at time of purchase (required, ≥ 0)
|
|
||||||
|
|
||||||
### Relationships
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐
|
|
||||||
│ Shops │ │ Shopping Event Groceries │ │ Groceries │
|
|
||||||
│ │ │ (Association Table) │ │ │
|
|
||||||
│ • id │ ←──────→│ • shopping_event_id │ ←──────→│ • id │
|
|
||||||
│ • name │ 1:N │ • grocery_id │ N:M │ • name │
|
|
||||||
│ • city │ │ • amount │ │ • category │
|
|
||||||
│ • address │ │ • price │ │ • organic │
|
|
||||||
│ • created_at │ │ │ │ • weight │
|
|
||||||
└─────────────────┘ └─────────────────────────────┘ │ • weight_unit │
|
|
||||||
│ │ │ • created_at │
|
|
||||||
│ │ │ • updated_at │
|
|
||||||
│ 1:N │ └─────────────────┘
|
|
||||||
▼ │
|
|
||||||
┌─────────────────┐ │
|
|
||||||
│ Shopping Events │ ←───────────────────────┘
|
|
||||||
│ │ 1:N
|
|
||||||
│ • id │
|
|
||||||
│ • shop_id │
|
|
||||||
│ • date │
|
|
||||||
│ • total_amount │
|
|
||||||
│ • notes │
|
|
||||||
│ • created_at │
|
|
||||||
└─────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
|
|
||||||
- **Price History**: Each grocery 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.)
|
|
||||||
- **Audit Trail**: All entities have creation timestamps for tracking
|
|
||||||
- **Data Integrity**: Foreign key constraints ensure referential integrity
|
|
||||||
|
|
||||||
## Setup Instructions
|
## Setup Instructions
|
||||||
|
|
||||||
|
|||||||
@ -200,7 +200,7 @@ def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depe
|
|||||||
|
|
||||||
@app.get("/shopping-events/", response_model=List[schemas.ShoppingEventResponse])
|
@app.get("/shopping-events/", response_model=List[schemas.ShoppingEventResponse])
|
||||||
def read_shopping_events(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
def read_shopping_events(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||||
events = db.query(models.ShoppingEvent).order_by(models.ShoppingEvent.created_at.desc()).offset(skip).limit(limit).all()
|
events = db.query(models.ShoppingEvent).offset(skip).limit(limit).all()
|
||||||
return [build_shopping_event_response(event, db) for event in events]
|
return [build_shopping_event_response(event, db) for event in events]
|
||||||
|
|
||||||
@app.get("/shopping-events/{event_id}", response_model=schemas.ShoppingEventResponse)
|
@app.get("/shopping-events/{event_id}", response_model=schemas.ShoppingEventResponse)
|
||||||
|
|||||||
@ -10,9 +10,8 @@ Base = declarative_base()
|
|||||||
shopping_event_groceries = Table(
|
shopping_event_groceries = Table(
|
||||||
'shopping_event_groceries',
|
'shopping_event_groceries',
|
||||||
Base.metadata,
|
Base.metadata,
|
||||||
Column('id', Integer, primary_key=True, autoincrement=True), # Artificial primary key
|
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), primary_key=True),
|
||||||
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), nullable=False),
|
Column('grocery_id', Integer, ForeignKey('groceries.id'), primary_key=True),
|
||||||
Column('grocery_id', Integer, ForeignKey('groceries.id'), nullable=False),
|
|
||||||
Column('amount', Float, nullable=False), # Amount of this grocery bought in this event
|
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('price', Float, nullable=False) # Price of this grocery at the time of this shopping event
|
||||||
)
|
)
|
||||||
|
|||||||
@ -53,7 +53,7 @@ class Shop(ShopBase):
|
|||||||
class GroceryInEvent(BaseModel):
|
class GroceryInEvent(BaseModel):
|
||||||
grocery_id: int
|
grocery_id: int
|
||||||
amount: float = Field(..., gt=0)
|
amount: float = Field(..., gt=0)
|
||||||
price: float = Field(..., ge=0) # Price at the time of this shopping event (allow free items)
|
price: float = Field(..., gt=0) # Price at the time of this shopping event
|
||||||
|
|
||||||
class GroceryWithEventData(BaseModel):
|
class GroceryWithEventData(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
|
|||||||
@ -28,7 +28,6 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
amount: 1,
|
amount: 1,
|
||||||
price: 0
|
price: 0
|
||||||
});
|
});
|
||||||
const [autoCalculate, setAutoCalculate] = useState<boolean>(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchShops();
|
fetchShops();
|
||||||
@ -38,23 +37,6 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [id, isEditMode]);
|
}, [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);
|
|
||||||
return Math.round(total * 100) / 100; // Round to 2 decimal places to avoid floating-point errors
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update total amount whenever selectedGroceries changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoCalculate) {
|
|
||||||
const calculatedTotal = calculateTotal(selectedGroceries);
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
total_amount: calculatedTotal > 0 ? calculatedTotal : undefined
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [selectedGroceries, autoCalculate]);
|
|
||||||
|
|
||||||
const fetchShops = async () => {
|
const fetchShops = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await shopApi.getAll();
|
const response = await shopApi.getAll();
|
||||||
@ -86,20 +68,6 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
formattedDate = event.date.split('T')[0];
|
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
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Calculate the sum of all groceries
|
|
||||||
const calculatedTotal = calculateTotal(mappedGroceries);
|
|
||||||
|
|
||||||
// Check if existing total matches calculated total (with small tolerance for floating point)
|
|
||||||
const existingTotal = event.total_amount || 0;
|
|
||||||
const totalMatches = Math.abs(existingTotal - calculatedTotal) < 0.01;
|
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
shop_id: event.shop.id,
|
shop_id: event.shop.id,
|
||||||
date: formattedDate,
|
date: formattedDate,
|
||||||
@ -108,8 +76,11 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
groceries: []
|
groceries: []
|
||||||
});
|
});
|
||||||
|
|
||||||
setSelectedGroceries(mappedGroceries);
|
setSelectedGroceries(event.groceries.map(g => ({
|
||||||
setAutoCalculate(totalMatches); // Enable auto-calc if totals match, disable if they don't
|
grocery_id: g.id,
|
||||||
|
amount: g.amount,
|
||||||
|
price: g.price
|
||||||
|
})));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching shopping event:', error);
|
console.error('Error fetching shopping event:', error);
|
||||||
setMessage('Error loading shopping event. Please try again.');
|
setMessage('Error loading shopping event. Please try again.');
|
||||||
@ -187,10 +158,7 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
|
|
||||||
const getGroceryName = (id: number) => {
|
const getGroceryName = (id: number) => {
|
||||||
const grocery = groceries.find(g => g.id === id);
|
const grocery = groceries.find(g => g.id === id);
|
||||||
if (!grocery) return 'Unknown';
|
return grocery ? grocery.name : 'Unknown';
|
||||||
|
|
||||||
const weightInfo = grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : grocery.weight_unit;
|
|
||||||
return `${grocery.name} ${weightInfo}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loadingEvent) {
|
if (loadingEvent) {
|
||||||
@ -282,7 +250,7 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
<option value={0}>Select a grocery</option>
|
<option value={0}>Select a grocery</option>
|
||||||
{groceries.map(grocery => (
|
{groceries.map(grocery => (
|
||||||
<option key={grocery.id} value={grocery.id}>
|
<option key={grocery.id} value={grocery.id}>
|
||||||
{grocery.name} ({grocery.category}) {grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : grocery.weight_unit}
|
{grocery.name} ({grocery.category})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -332,14 +300,9 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
|
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
|
||||||
{selectedGroceries.map((item, index) => (
|
{selectedGroceries.map((item, index) => (
|
||||||
<div key={index} className="flex justify-between items-center py-2 border-b last:border-b-0">
|
<div key={index} className="flex justify-between items-center py-2 border-b last:border-b-0">
|
||||||
<div className="flex-1">
|
<span>
|
||||||
<div className="text-sm text-gray-900">
|
{getGroceryName(item.grocery_id)} x {item.amount} @ ${item.price.toFixed(2)}
|
||||||
{getGroceryName(item.grocery_id)}
|
</span>
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600">
|
|
||||||
{item.amount} × ${item.price.toFixed(2)} = ${(item.amount * item.price).toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -364,43 +327,18 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
|
|
||||||
{/* Total Amount */}
|
{/* Total Amount */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
Total Amount (optional)
|
||||||
Total Amount
|
</label>
|
||||||
</label>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={autoCalculate}
|
|
||||||
onChange={(e) => setAutoCalculate(e.target.checked)}
|
|
||||||
className="sr-only peer"
|
|
||||||
/>
|
|
||||||
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
|
|
||||||
</label>
|
|
||||||
<span className={`text-xs px-2 py-1 rounded ${autoCalculate ? 'text-green-600 bg-green-50' : 'text-gray-500 bg-gray-100'}`}>
|
|
||||||
Auto-calculated
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
value={formData.total_amount || ''}
|
value={formData.total_amount || ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => setFormData({...formData, total_amount: e.target.value ? parseFloat(e.target.value) : undefined})}
|
||||||
setFormData({...formData, total_amount: e.target.value ? parseFloat(e.target.value) : undefined});
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
setAutoCalculate(false); // Disable auto-calculation when manually editing
|
|
||||||
}}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 text-right"
|
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
{autoCalculate
|
|
||||||
? "This field is automatically calculated from your selected items. You can manually edit it if needed."
|
|
||||||
: "Auto-calculation is disabled. You can manually enter the total amount or enable auto-calculation above."
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user