diff --git a/.claude/commands/pr-description.md b/.claude/commands/pr-description.md new file mode 100644 index 0000000..02b0bed --- /dev/null +++ b/.claude/commands/pr-description.md @@ -0,0 +1,27 @@ +Write a PR description for the current branch and save it to `docs/pr-description.md`. + +Use `git log main..HEAD` and `git diff main..HEAD` to understand what changed. + +Rules: +- Do not list files changed — that's visible in the diff +- Focus on WHY, not WHAT +- Explain decisions that aren't obvious from the code +- Note anything a reviewer should pay attention to +- Keep it concise — one bullet per decision, one sharp reason per bullet +- End with a "What's next" line if there's a clear next step +- Use plain GitHub-flavoured markdown, no emoji +- Tone: professional, humble, clear + +Sharpness rules: +- Don't restate the title in the opening summary — if the sentence adds nothing, cut it +- "What is in diff, is in diff" — never describe which files changed or say "this is reflected in X, Y, Z" +- No anecdote or backstory ("this bit us", "we discovered") — state the technical constraint directly +- Each bullet: decision + the one sharp reason it was made, nothing else +- Don't over-explain consequences that follow obviously from the constraint +- Cut filler: "zero complexity", "available to anyone", "particularly useful for" + +Structure: +## +<1 sentence — only if it adds something the title doesn't> +### Decisions worth noting +### What's next diff --git a/.gitignore b/.gitignore index 51a5f60..b933490 100644 --- a/.gitignore +++ b/.gitignore @@ -229,4 +229,5 @@ frontend/.vite/ Thumbs.db # Project specific -docs/context.md \ No newline at end of file +docs/context.md +docs/pr-description.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index b368a10..fce96e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,11 +54,12 @@ uv run pytest tests/path/to/test_file.py::test_name Household → User (many per household) → Location (e.g. Fridge, Cellar, Pantry — seeded on creation) → Category (e.g. Food, Cleaning — seeded on creation) - → Item (what a product *is*; has barcode, location, category) - → Batch (one per purchase; holds quantity + expiry date) + → Item (what a product *is*; has barcode, brand, image_url, category) + → Batch (one per unique location + expiry combo; holds quantity, location, expiry) ``` -- **Item** = the product definition. **Batch** = a specific purchase of that item with its own quantity and expiry. +- **Item** = the product definition (name, barcode, brand, image_url from Open Food Facts, category, unit). No location — the same product can live in multiple places. +- **Batch** = one group of units sharing the same location and expiry date. A purchase split across two locations creates two batches. - Expiry granularity is month + year only; defaults to +12 months from today. - `ShoppingListItem` model exists in v1 but has no UI — that's a v2 feature. @@ -80,6 +81,22 @@ FastAPI Users with JWT tokens. Registration creates both a new User and a new Ho - Ruff enforces strict rules including mandatory type annotations (`ANN`) and no `print` statements (`T20`). Tests are exempt from annotations and security rules — see `pyproject.toml` for the full per-file ignore list. - `asyncio_mode = "auto"` is set in pytest — no need to mark async tests explicitly. +### Datetime columns + +All `datetime` fields must use `sa_column=sa.Column(sa.DateTime(timezone=True))` — asyncpg rejects timezone-aware datetimes in `TIMESTAMP WITHOUT TIME ZONE` columns. Always use `datetime.now(UTC)` (never `datetime.utcnow()`). + +### Schema changes + +No Alembic yet. Schema changes require dropping affected tables in the Neon console and restarting the server to recreate them. Add Alembic before any production data exists. + +### fastapi-users imports + +Import `SQLAlchemyUserDatabase` directly from `fastapi_users_db_sqlalchemy`, not `fastapi_users.db`. The re-export in `fastapi_users.db` uses a `try/except` block that Pylance cannot statically trace, causing false unknown-symbol errors. + +## Developer environment + +Host machine: Apple M4 MacBook (ARM64, no Rosetta). Package manager: Homebrew. Shell: zsh. `gh` CLI is installed on the host — not inside the Claude Code container. + ## CI A GitHub Actions workflow (`.github/workflows/claude-review.yml`) runs Claude Code review on every non-draft PR to `main`. It focuses on data isolation bugs, missing input validation, security issues, and learning-oriented feedback. diff --git a/backend/app/auth/users.py b/backend/app/auth/users.py index 08a88b6..0ea6933 100644 --- a/backend/app/auth/users.py +++ b/backend/app/auth/users.py @@ -11,7 +11,9 @@ from app.auth.backend import auth_backend from app.config import get_settings from app.database import get_session +from app.models.category import Category from app.models.household import Household +from app.models.location import Location from app.models.user import User @@ -48,6 +50,8 @@ async def on_after_register( self.session.add(household) await self.session.flush() # writes household row and populates household.id user.household_id = household.id # session tracks this change automatically + self.session.add(Location(household_id=household.id, name="Pantry")) + self.session.add(Category(household_id=household.id, name="Food")) await self.session.commit() except Exception: await self.user_db.delete(user) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 45aaa88..1228f09 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,4 +1,8 @@ +from app.models.batch import Batch +from app.models.category import Category from app.models.household import Household +from app.models.item import Item +from app.models.location import Location from app.models.user import User -__all__ = ["Household", "User"] +__all__ = ["Batch", "Category", "Household", "Item", "Location", "User"] diff --git a/backend/app/models/batch.py b/backend/app/models/batch.py new file mode 100644 index 0000000..a5812f8 --- /dev/null +++ b/backend/app/models/batch.py @@ -0,0 +1,25 @@ +from datetime import UTC, date, datetime, timedelta + +import sqlalchemy as sa +from sqlmodel import Field, SQLModel + + +class Batch(SQLModel, table=True): + __tablename__ = "batches" # type: ignore[assignment] + + id: int | None = Field(default=None, primary_key=True) + # Denormalized from Item for query-level household isolation — every batch query can filter + # directly on household_id without joining through items. + household_id: int = Field(foreign_key="households.id", index=True) + item_id: int = Field(foreign_key="items.id", index=True) + quantity: int = Field(sa_column=sa.Column(sa.Integer, sa.CheckConstraint("quantity > 0"), nullable=False)) + # Defaults to approximately +12 months from today. + # Month + year precision only — the day component is not shown in the UI. + expiry_date: date = Field( + default_factory=lambda: date.today() + timedelta(days=365) + ) + location_id: int = Field(foreign_key="locations.id") + created_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), + sa_column=sa.Column(sa.DateTime(timezone=True), nullable=False), + ) diff --git a/backend/app/models/category.py b/backend/app/models/category.py new file mode 100644 index 0000000..913e48f --- /dev/null +++ b/backend/app/models/category.py @@ -0,0 +1,11 @@ +import sqlalchemy as sa +from sqlmodel import Field, SQLModel + + +class Category(SQLModel, table=True): + __tablename__ = "categories" # type: ignore[assignment] + __table_args__ = (sa.UniqueConstraint("household_id", "name", name="uq_category_household_name"),) + + id: int | None = Field(default=None, primary_key=True) + household_id: int = Field(foreign_key="households.id", index=True) + name: str diff --git a/backend/app/models/item.py b/backend/app/models/item.py new file mode 100644 index 0000000..62a1ab7 --- /dev/null +++ b/backend/app/models/item.py @@ -0,0 +1,35 @@ +from datetime import UTC, datetime + +import sqlalchemy as sa +from sqlmodel import Field, SQLModel + + +class Item(SQLModel, table=True): + __tablename__ = "items" # type: ignore[assignment] + # NULL barcodes are exempt — PostgreSQL does not consider NULLs equal in unique constraints, + # so multiple items without a barcode in the same household are allowed. + __table_args__ = (sa.UniqueConstraint("household_id", "barcode", name="uq_item_household_barcode"),) + + id: int | None = Field(default=None, primary_key=True) + household_id: int = Field(foreign_key="households.id", index=True) + name: str + barcode: str | None = Field(default=None, index=True) + brand: str | None = Field(default=None) + image_url: str | None = Field(default=None) + # Nullable: category may be unknown if barcode lookup returns no result. + category_id: int | None = Field(default=None, foreign_key="categories.id") + unit: str + notes: str | None = None + created_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), + sa_column=sa.Column(sa.DateTime(timezone=True), nullable=False), + ) + # onupdate fires automatically on every ORM-level UPDATE to this row. + updated_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), + sa_column=sa.Column( + sa.DateTime(timezone=True), + nullable=False, + onupdate=lambda: datetime.now(UTC), + ), + ) diff --git a/backend/app/models/location.py b/backend/app/models/location.py new file mode 100644 index 0000000..5732bf6 --- /dev/null +++ b/backend/app/models/location.py @@ -0,0 +1,11 @@ +import sqlalchemy as sa +from sqlmodel import Field, SQLModel + + +class Location(SQLModel, table=True): + __tablename__ = "locations" # type: ignore[assignment] + __table_args__ = (sa.UniqueConstraint("household_id", "name", name="uq_location_household_name"),) + + id: int | None = Field(default=None, primary_key=True) + household_id: int = Field(foreign_key="households.id", index=True) + name: str diff --git a/docs/spec.md b/docs/spec.md index 5d30e1b..963d7da 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -99,24 +99,30 @@ The product — what something *is*, not how much you have. | household_id | int | FK → Household | | name | str | | | barcode | str | Optional, from scan | +| brand | str | Optional, from Open Food Facts | +| image_url | str | Optional, from Open Food Facts | | category_id | int | FK → Category | -| location_id | int | FK → Location, editable | | unit | str | e.g. "pieces", "bottles", "kg" | | notes | str | Optional | | created_at | datetime | | | updated_at | datetime | | +**Note:** `location_id` is not on Item — location is tracked per Batch, since the same product can be stored in multiple places at once. + ### `Batch` -One entry per purchase of an item. Holds quantity and expiry. +One group of units sharing the same location and expiry date. | Field | Type | Notes | |---|---|---| | id | int | Primary key | | item_id | int | FK → Item | +| location_id | int | FK → Location | | quantity | int | | | expiry_date | date | Month + year precision, defaults to +12 months | | created_at | datetime | | -**Why Batch exists:** The same product (e.g. pasta) can be bought multiple times with different expiry dates. Each purchase is a separate batch. +**Why Batch exists:** The same product can be bought multiple times with different expiry dates, and/or stored across multiple locations. Each unique combination of location + expiry = one batch. + +**Real-world example:** Buy 6 passatas → create two batches: `{quantity: 4, location: cellar, expiry: June 2027}` and `{quantity: 2, location: pantry, expiry: June 2027}`. Inventory view shows total = 6, broken down by location. **Alert logic:** If total quantity across all batches for an item = 0 → flag for shopping list (v2). @@ -140,15 +146,16 @@ One entry per purchase of an item. Holds quantity and expiry. 1. User taps **Scan** on home screen 2. Last used location is prefilled 3. Camera opens, user scans barcode (ZXing-js) -4. App queries Open Food Facts → prefills name, category -5. **If existing product:** show current batches + quantities, user can add new batch or adjust existing +4. App queries Open Food Facts → prefills name, brand, image, category +5. **If existing product:** show current batches + quantities (grouped by location), user can add new batch or adjust existing 6. **If new product:** form shown with prefilled name/category, user enters quantity + unit, expiry defaults to current month + 12 months (month/year picker), all fields editable -7. Save → written to DB +7. **Split by location:** user can tap "Add another location" to create a second batch entry for the same purchase (e.g. 4 to cellar, 2 to pantry) +8. Save → written to DB ### Flow 2: Inventory view -1. List of all items with total quantity and location +1. List of all items with total quantity across all locations 2. Searchable/filterable by: name, location, expiry date -3. Tap item → detail view showing all batches +3. Tap item → detail view showing all batches (each with location, quantity, expiry) 4. From detail: edit any field, adjust quantity per batch (+1 / -1 or type number) ### Flow 3: Expiring soon @@ -275,9 +282,11 @@ Registration forks into two paths depending on whether an invite token is presen | Notifications | None in v1 | Filtered list in app; agent emails in v2 | | Frontend | React PWA (Vite) | Known stack, mobile-friendly, installable | | Barcode scanning | ZXing-js | Browser-based, no native app needed | -| Image upload | Not in v1 | Unnecessary complexity | +| Image upload | Not in v1 | Unnecessary complexity; image_url from OFF is sufficient | | Multi-household user | One household per user | Simpler for v1; invite flow in v2 handles joining an existing household without changing this constraint | +| Location on Batch not Item | location_id on Batch | Same product can live in multiple locations (e.g. cellar + pantry); batch = unique combo of location + expiry | | Location default | Last used location | Lowest friction for scanning session | +| brand + image_url on Item | Added in v1 | Single fields, zero complexity; brand disambiguates products; retrofitting requires migration | --- @@ -289,7 +298,7 @@ Registration forks into two paths depending on whether an invite token is presen - Shopping list UI - Agent / email summaries - Recipe suggestions -- Image upload +- Image upload (image_url from Open Food Facts only) - Multi-household per user - Invite flow UI (backend design is specced in section 6, build in v2) @@ -307,4 +316,4 @@ Registration forks into two paths depending on whether an invite token is presen --- -*Last updated: May 2026. Built with FastAPI + SQLModel + React PWA.* \ No newline at end of file +*Last updated: June 2026. Built with FastAPI + SQLModel + React PWA.* \ No newline at end of file