diff --git a/python/orm/van_inventory/models.py b/python/orm/van_inventory/models.py
new file mode 100644
index 0000000..2672285
--- /dev/null
+++ b/python/orm/van_inventory/models.py
@@ -0,0 +1,46 @@
+"""Van inventory ORM models."""
+
+from __future__ import annotations
+
+from sqlalchemy import ForeignKey, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from python.orm.van_inventory.base import VanTableBase
+
+
+class Item(VanTableBase):
+ """A food item in the van."""
+
+ __tablename__ = "items"
+
+ name: Mapped[str] = mapped_column(unique=True)
+ quantity: Mapped[float] = mapped_column(default=0)
+ unit: Mapped[str]
+ category: Mapped[str | None]
+
+ meal_ingredients: Mapped[list[MealIngredient]] = relationship(back_populates="item")
+
+
+class Meal(VanTableBase):
+ """A meal that can be made from items in the van."""
+
+ __tablename__ = "meals"
+
+ name: Mapped[str] = mapped_column(unique=True)
+ instructions: Mapped[str | None]
+
+ ingredients: Mapped[list[MealIngredient]] = relationship(back_populates="meal")
+
+
+class MealIngredient(VanTableBase):
+ """Links a meal to the items it requires, with quantities."""
+
+ __tablename__ = "meal_ingredients"
+ __table_args__ = (UniqueConstraint("meal_id", "item_id"),)
+
+ meal_id: Mapped[int] = mapped_column(ForeignKey("meals.id"))
+ item_id: Mapped[int] = mapped_column(ForeignKey("items.id"))
+ quantity_needed: Mapped[float]
+
+ meal: Mapped[Meal] = relationship(back_populates="ingredients")
+ item: Mapped[Item] = relationship(back_populates="meal_ingredients")
diff --git a/python/van_inventory/__init__.py b/python/van_inventory/__init__.py
new file mode 100644
index 0000000..ea8dd57
--- /dev/null
+++ b/python/van_inventory/__init__.py
@@ -0,0 +1 @@
+"""Van inventory FastAPI application."""
diff --git a/python/van_inventory/dependencies.py b/python/van_inventory/dependencies.py
new file mode 100644
index 0000000..2d085f3
--- /dev/null
+++ b/python/van_inventory/dependencies.py
@@ -0,0 +1,16 @@
+"""FastAPI dependencies for van inventory."""
+
+from collections.abc import Iterator
+from typing import Annotated
+
+from fastapi import Depends, Request
+from sqlalchemy.orm import Session
+
+
+def get_db(request: Request) -> Iterator[Session]:
+ """Get database session from app state."""
+ with Session(request.app.state.engine) as session:
+ yield session
+
+
+DbSession = Annotated[Session, Depends(get_db)]
diff --git a/python/van_inventory/main.py b/python/van_inventory/main.py
new file mode 100644
index 0000000..594f018
--- /dev/null
+++ b/python/van_inventory/main.py
@@ -0,0 +1,51 @@
+"""FastAPI app for van inventory."""
+
+from __future__ import annotations
+
+import logging
+from contextlib import asynccontextmanager
+from typing import TYPE_CHECKING, Annotated
+
+import typer
+import uvicorn
+from fastapi import FastAPI
+
+from python.common import configure_logger
+from python.orm.common import get_postgres_engine
+from python.van_inventory.routers import api_router, frontend_router
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncIterator
+
+logger = logging.getLogger(__name__)
+
+
+def create_app() -> FastAPI:
+ """Create and configure the FastAPI application."""
+
+ @asynccontextmanager
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
+ app.state.engine = get_postgres_engine(name="VAN_INVENTORY")
+ yield
+ app.state.engine.dispose()
+
+ app = FastAPI(title="Van Inventory", lifespan=lifespan)
+ app.include_router(api_router)
+ app.include_router(frontend_router)
+ return app
+
+
+def serve(
+ # Intentionally binds all interfaces — this is a LAN-only van server
+ host: Annotated[str, typer.Option("--host", "-h", help="Host to bind to")] = "0.0.0.0", # noqa: S104
+ port: Annotated[int, typer.Option("--port", "-p", help="Port to bind to")] = 8001,
+ log_level: Annotated[str, typer.Option("--log-level", "-l", help="Log level")] = "INFO",
+) -> None:
+ """Start the Van Inventory server."""
+ configure_logger(log_level)
+ app = create_app()
+ uvicorn.run(app, host=host, port=port)
+
+
+if __name__ == "__main__":
+ typer.run(serve)
diff --git a/python/van_inventory/routers/__init__.py b/python/van_inventory/routers/__init__.py
new file mode 100644
index 0000000..39ef29b
--- /dev/null
+++ b/python/van_inventory/routers/__init__.py
@@ -0,0 +1,6 @@
+"""Van inventory API routers."""
+
+from python.van_inventory.routers.api import router as api_router
+from python.van_inventory.routers.frontend import router as frontend_router
+
+__all__ = ["api_router", "frontend_router"]
diff --git a/python/van_inventory/routers/api.py b/python/van_inventory/routers/api.py
new file mode 100644
index 0000000..f9e97eb
--- /dev/null
+++ b/python/van_inventory/routers/api.py
@@ -0,0 +1,319 @@
+"""Van inventory API router."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.orm import selectinload
+
+from python.orm.van_inventory.models import Item, Meal, MealIngredient
+
+if TYPE_CHECKING:
+ from python.van_inventory.dependencies import DbSession
+
+
+# --- Schemas ---
+
+
+class ItemCreate(BaseModel):
+ """Schema for creating an item."""
+
+ name: str
+ quantity: float = 0
+ unit: str
+ category: str | None = None
+
+
+class ItemUpdate(BaseModel):
+ """Schema for updating an item."""
+
+ name: str | None = None
+ quantity: float | None = None
+ unit: str | None = None
+ category: str | None = None
+
+
+class ItemResponse(BaseModel):
+ """Schema for item response."""
+
+ id: int
+ name: str
+ quantity: float
+ unit: str
+ category: str | None
+
+ model_config = {"from_attributes": True}
+
+
+class IngredientCreate(BaseModel):
+ """Schema for adding an ingredient to a meal."""
+
+ item_id: int
+ quantity_needed: float
+
+
+class MealCreate(BaseModel):
+ """Schema for creating a meal."""
+
+ name: str
+ instructions: str | None = None
+ ingredients: list[IngredientCreate] = []
+
+
+class MealUpdate(BaseModel):
+ """Schema for updating a meal."""
+
+ name: str | None = None
+ instructions: str | None = None
+
+
+class IngredientResponse(BaseModel):
+ """Schema for ingredient response."""
+
+ item_id: int
+ item_name: str
+ quantity_needed: float
+ unit: str
+
+ model_config = {"from_attributes": True}
+
+
+class MealResponse(BaseModel):
+ """Schema for meal response."""
+
+ id: int
+ name: str
+ instructions: str | None
+ ingredients: list[IngredientResponse] = []
+
+ model_config = {"from_attributes": True}
+
+ @classmethod
+ def from_meal(cls, meal: Meal) -> MealResponse:
+ """Build a MealResponse from an ORM Meal with loaded ingredients."""
+ return cls(
+ id=meal.id,
+ name=meal.name,
+ instructions=meal.instructions,
+ ingredients=[
+ IngredientResponse(
+ item_id=mi.item_id,
+ item_name=mi.item.name,
+ quantity_needed=mi.quantity_needed,
+ unit=mi.item.unit,
+ )
+ for mi in meal.ingredients
+ ],
+ )
+
+
+class ShoppingItem(BaseModel):
+ """An item needed for a meal that is short on stock."""
+
+ item_name: str
+ unit: str
+ needed: float
+ have: float
+ short: float
+
+
+class MealAvailability(BaseModel):
+ """Availability status for a meal."""
+
+ meal_id: int
+ meal_name: str
+ can_make: bool
+ missing: list[ShoppingItem] = []
+
+
+# --- Routes ---
+
+router = APIRouter(prefix="/api", tags=["van_inventory"])
+
+
+# Items
+
+
+@router.post("/items", response_model=ItemResponse)
+def create_item(item: ItemCreate, db: DbSession) -> Item:
+ """Create a new inventory item."""
+ db_item = Item(**item.model_dump())
+ db.add(db_item)
+ db.commit()
+ db.refresh(db_item)
+ return db_item
+
+
+@router.get("/items", response_model=list[ItemResponse])
+def list_items(db: DbSession) -> list[Item]:
+ """List all inventory items."""
+ return list(db.scalars(select(Item).order_by(Item.name)).all())
+
+
+@router.get("/items/{item_id}", response_model=ItemResponse)
+def get_item(item_id: int, db: DbSession) -> Item:
+ """Get an item by ID."""
+ item = db.get(Item, item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Item not found")
+ return item
+
+
+@router.patch("/items/{item_id}", response_model=ItemResponse)
+def update_item(item_id: int, item: ItemUpdate, db: DbSession) -> Item:
+ """Update an item by ID."""
+ db_item = db.get(Item, item_id)
+ if not db_item:
+ raise HTTPException(status_code=404, detail="Item not found")
+ for key, value in item.model_dump(exclude_unset=True).items():
+ setattr(db_item, key, value)
+ db.commit()
+ db.refresh(db_item)
+ return db_item
+
+
+@router.delete("/items/{item_id}")
+def delete_item(item_id: int, db: DbSession) -> dict[str, bool]:
+ """Delete an item by ID."""
+ item = db.get(Item, item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Item not found")
+ db.delete(item)
+ db.commit()
+ return {"deleted": True}
+
+
+# Meals
+
+
+@router.post("/meals", response_model=MealResponse)
+def create_meal(meal: MealCreate, db: DbSession) -> MealResponse:
+ """Create a new meal with optional ingredients."""
+ db_meal = Meal(name=meal.name, instructions=meal.instructions)
+ db.add(db_meal)
+ db.flush()
+ for ing in meal.ingredients:
+ db.add(MealIngredient(meal_id=db_meal.id, item_id=ing.item_id, quantity_needed=ing.quantity_needed))
+ db.commit()
+ db_meal = db.scalar(
+ select(Meal)
+ .where(Meal.id == db_meal.id)
+ .options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
+ )
+ return MealResponse.from_meal(db_meal)
+
+
+@router.get("/meals", response_model=list[MealResponse])
+def list_meals(db: DbSession) -> list[MealResponse]:
+ """List all meals with ingredients."""
+ meals = list(
+ db.scalars(
+ select(Meal)
+ .options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
+ .order_by(Meal.name)
+ ).all()
+ )
+ return [MealResponse.from_meal(m) for m in meals]
+
+
+@router.get("/meals/{meal_id}", response_model=MealResponse)
+def get_meal(meal_id: int, db: DbSession) -> MealResponse:
+ """Get a meal by ID with ingredients."""
+ meal = db.scalar(
+ select(Meal)
+ .where(Meal.id == meal_id)
+ .options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
+ )
+ if not meal:
+ raise HTTPException(status_code=404, detail="Meal not found")
+ return MealResponse.from_meal(meal)
+
+
+@router.delete("/meals/{meal_id}")
+def delete_meal(meal_id: int, db: DbSession) -> dict[str, bool]:
+ """Delete a meal by ID."""
+ meal = db.get(Meal, meal_id)
+ if not meal:
+ raise HTTPException(status_code=404, detail="Meal not found")
+ db.delete(meal)
+ db.commit()
+ return {"deleted": True}
+
+
+@router.post("/meals/{meal_id}/ingredients", response_model=MealResponse)
+def add_ingredient(meal_id: int, ingredient: IngredientCreate, db: DbSession) -> MealResponse:
+ """Add an ingredient to a meal."""
+ meal = db.get(Meal, meal_id)
+ if not meal:
+ raise HTTPException(status_code=404, detail="Meal not found")
+ db.add(MealIngredient(meal_id=meal_id, item_id=ingredient.item_id, quantity_needed=ingredient.quantity_needed))
+ db.commit()
+ meal = db.scalar(
+ select(Meal)
+ .where(Meal.id == meal_id)
+ .options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
+ )
+ return MealResponse.from_meal(meal)
+
+
+@router.delete("/meals/{meal_id}/ingredients/{item_id}")
+def remove_ingredient(meal_id: int, item_id: int, db: DbSession) -> dict[str, bool]:
+ """Remove an ingredient from a meal."""
+ mi = db.scalar(
+ select(MealIngredient).where(MealIngredient.meal_id == meal_id, MealIngredient.item_id == item_id)
+ )
+ if not mi:
+ raise HTTPException(status_code=404, detail="Ingredient not found")
+ db.delete(mi)
+ db.commit()
+ return {"deleted": True}
+
+
+# What can I make / what do I need
+
+
+@router.get("/meals/availability", response_model=list[MealAvailability])
+def check_all_meals(db: DbSession) -> list[MealAvailability]:
+ """Check which meals can be made with current inventory."""
+ meals = list(
+ db.scalars(
+ select(Meal).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
+ ).all()
+ )
+ return [_check_meal(m) for m in meals]
+
+
+@router.get("/meals/{meal_id}/availability", response_model=MealAvailability)
+def check_meal(meal_id: int, db: DbSession) -> MealAvailability:
+ """Check if a specific meal can be made and what's missing."""
+ meal = db.scalar(
+ select(Meal)
+ .where(Meal.id == meal_id)
+ .options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
+ )
+ if not meal:
+ raise HTTPException(status_code=404, detail="Meal not found")
+ return _check_meal(meal)
+
+
+def _check_meal(meal: Meal) -> MealAvailability:
+ missing = [
+ ShoppingItem(
+ item_name=mi.item.name,
+ unit=mi.item.unit,
+ needed=mi.quantity_needed,
+ have=mi.item.quantity,
+ short=mi.quantity_needed - mi.item.quantity,
+ )
+ for mi in meal.ingredients
+ if mi.item.quantity < mi.quantity_needed
+ ]
+ return MealAvailability(
+ meal_id=meal.id,
+ meal_name=meal.name,
+ can_make=len(missing) == 0,
+ missing=missing,
+ )
diff --git a/python/van_inventory/routers/frontend.py b/python/van_inventory/routers/frontend.py
new file mode 100644
index 0000000..e38c074
--- /dev/null
+++ b/python/van_inventory/routers/frontend.py
@@ -0,0 +1,188 @@
+"""HTMX frontend routes for van inventory."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import TYPE_CHECKING, Annotated
+
+from fastapi import APIRouter, Form, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+from sqlalchemy import select
+from sqlalchemy.orm import selectinload
+
+from python.orm.van_inventory.models import Item, Meal, MealIngredient
+from python.van_inventory.routers.api import _check_meal
+
+if TYPE_CHECKING:
+ from python.van_inventory.dependencies import DbSession
+
+TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
+templates = Jinja2Templates(directory=TEMPLATE_DIR)
+
+router = APIRouter(tags=["frontend"])
+
+
+# --- Items ---
+
+
+@router.get("/", response_class=HTMLResponse)
+def items_page(request: Request, db: DbSession) -> HTMLResponse:
+ """Render the inventory page."""
+ items = list(db.scalars(select(Item).order_by(Item.name)).all())
+ return templates.TemplateResponse(request, "items.html", {"items": items})
+
+
+@router.post("/items", response_class=HTMLResponse)
+def htmx_create_item(
+ request: Request,
+ db: DbSession,
+ name: Annotated[str, Form()],
+ quantity: Annotated[float, Form()] = 0,
+ unit: Annotated[str, Form()] = "",
+ category: Annotated[str | None, Form()] = None,
+) -> HTMLResponse:
+ """Create an item and return updated item rows."""
+ db.add(Item(name=name, quantity=quantity, unit=unit, category=category or None))
+ db.commit()
+ items = list(db.scalars(select(Item).order_by(Item.name)).all())
+ return templates.TemplateResponse(request, "partials/item_rows.html", {"items": items})
+
+
+@router.patch("/items/{item_id}", response_class=HTMLResponse)
+def htmx_update_item(
+ request: Request,
+ item_id: int,
+ db: DbSession,
+ quantity: Annotated[float, Form()],
+) -> HTMLResponse:
+ """Update an item's quantity and return updated item rows."""
+ item = db.get(Item, item_id)
+ if item:
+ item.quantity = quantity
+ db.commit()
+ items = list(db.scalars(select(Item).order_by(Item.name)).all())
+ return templates.TemplateResponse(request, "partials/item_rows.html", {"items": items})
+
+
+@router.delete("/items/{item_id}", response_class=HTMLResponse)
+def htmx_delete_item(request: Request, item_id: int, db: DbSession) -> HTMLResponse:
+ """Delete an item and return updated item rows."""
+ item = db.get(Item, item_id)
+ if item:
+ db.delete(item)
+ db.commit()
+ items = list(db.scalars(select(Item).order_by(Item.name)).all())
+ return templates.TemplateResponse(request, "partials/item_rows.html", {"items": items})
+
+
+# --- Meals ---
+
+
+def _load_meals(db: DbSession) -> list[Meal]:
+ return list(
+ db.scalars(
+ select(Meal)
+ .options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
+ .order_by(Meal.name)
+ ).all()
+ )
+
+
+@router.get("/meals", response_class=HTMLResponse)
+def meals_page(request: Request, db: DbSession) -> HTMLResponse:
+ """Render the meals page."""
+ meals = _load_meals(db)
+ return templates.TemplateResponse(request, "meals.html", {"meals": meals})
+
+
+@router.post("/meals", response_class=HTMLResponse)
+def htmx_create_meal(
+ request: Request,
+ db: DbSession,
+ name: Annotated[str, Form()],
+ instructions: Annotated[str | None, Form()] = None,
+) -> HTMLResponse:
+ """Create a meal and return updated meal rows."""
+ db.add(Meal(name=name, instructions=instructions or None))
+ db.commit()
+ meals = _load_meals(db)
+ return templates.TemplateResponse(request, "partials/meal_rows.html", {"meals": meals})
+
+
+@router.delete("/meals/{meal_id}", response_class=HTMLResponse)
+def htmx_delete_meal(request: Request, meal_id: int, db: DbSession) -> HTMLResponse:
+ """Delete a meal and return updated meal rows."""
+ meal = db.get(Meal, meal_id)
+ if meal:
+ db.delete(meal)
+ db.commit()
+ meals = _load_meals(db)
+ return templates.TemplateResponse(request, "partials/meal_rows.html", {"meals": meals})
+
+
+# --- Meal detail ---
+
+
+def _load_meal(db: DbSession, meal_id: int) -> Meal | None:
+ return db.scalar(
+ select(Meal)
+ .where(Meal.id == meal_id)
+ .options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
+ )
+
+
+@router.get("/meals/{meal_id}", response_class=HTMLResponse)
+def meal_detail_page(request: Request, meal_id: int, db: DbSession) -> HTMLResponse:
+ """Render the meal detail page."""
+ meal = _load_meal(db, meal_id)
+ items = list(db.scalars(select(Item).order_by(Item.name)).all())
+ return templates.TemplateResponse(request, "meal_detail.html", {"meal": meal, "items": items})
+
+
+@router.post("/meals/{meal_id}/ingredients", response_class=HTMLResponse)
+def htmx_add_ingredient(
+ request: Request,
+ meal_id: int,
+ db: DbSession,
+ item_id: Annotated[int, Form()],
+ quantity_needed: Annotated[float, Form()],
+) -> HTMLResponse:
+ """Add an ingredient to a meal and return updated ingredient rows."""
+ db.add(MealIngredient(meal_id=meal_id, item_id=item_id, quantity_needed=quantity_needed))
+ db.commit()
+ meal = _load_meal(db, meal_id)
+ return templates.TemplateResponse(request, "partials/ingredient_rows.html", {"meal": meal})
+
+
+@router.delete("/meals/{meal_id}/ingredients/{item_id}", response_class=HTMLResponse)
+def htmx_remove_ingredient(
+ request: Request,
+ meal_id: int,
+ item_id: int,
+ db: DbSession,
+) -> HTMLResponse:
+ """Remove an ingredient from a meal and return updated ingredient rows."""
+ mi = db.scalar(
+ select(MealIngredient).where(MealIngredient.meal_id == meal_id, MealIngredient.item_id == item_id)
+ )
+ if mi:
+ db.delete(mi)
+ db.commit()
+ meal = _load_meal(db, meal_id)
+ return templates.TemplateResponse(request, "partials/ingredient_rows.html", {"meal": meal})
+
+
+# --- Availability ---
+
+
+@router.get("/availability", response_class=HTMLResponse)
+def availability_page(request: Request, db: DbSession) -> HTMLResponse:
+ """Render the meal availability page."""
+ meals = list(
+ db.scalars(
+ select(Meal).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
+ ).all()
+ )
+ availability = [_check_meal(m) for m in meals]
+ return templates.TemplateResponse(request, "availability.html", {"availability": availability})
diff --git a/python/van_inventory/templates/availability.html b/python/van_inventory/templates/availability.html
new file mode 100644
index 0000000..da77be8
--- /dev/null
+++ b/python/van_inventory/templates/availability.html
@@ -0,0 +1,30 @@
+{% extends "base.html" %}
+{% block title %}What Can I Make? - Van{% endblock %}
+{% block content %}
+
What Can I Make?
+
+
+
+ | Meal | Status | Missing |
+
+
+ {% for meal in availability %}
+
+ | {{ meal.meal_name }} |
+
+ {% if meal.can_make %}
+ Ready
+ {% else %}
+ Missing items
+ {% endif %}
+ |
+
+ {% for m in meal.missing %}
+ {{ m.item_name }}: need {{ m.short }} more {{ m.unit }}{% if not loop.last %}, {% endif %}
+ {% endfor %}
+ |
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/python/van_inventory/templates/base.html b/python/van_inventory/templates/base.html
new file mode 100644
index 0000000..54ecf59
--- /dev/null
+++ b/python/van_inventory/templates/base.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+ {% block title %}Van Inventory{% endblock %}
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
diff --git a/python/van_inventory/templates/items.html b/python/van_inventory/templates/items.html
new file mode 100644
index 0000000..f82be9d
--- /dev/null
+++ b/python/van_inventory/templates/items.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+{% block title %}Inventory - Van{% endblock %}
+{% block content %}
+Van Inventory
+
+
+
+
+ {% include "partials/item_rows.html" %}
+
+{% endblock %}
diff --git a/python/van_inventory/templates/meal_detail.html b/python/van_inventory/templates/meal_detail.html
new file mode 100644
index 0000000..807eadc
--- /dev/null
+++ b/python/van_inventory/templates/meal_detail.html
@@ -0,0 +1,24 @@
+{% extends "base.html" %}
+{% block title %}{{ meal.name }} - Van{% endblock %}
+{% block content %}
+{{ meal.name }}
+{% if meal.instructions %}{{ meal.instructions }}
{% endif %}
+
+Ingredients
+
+
+
+ {% include "partials/ingredient_rows.html" %}
+
+{% endblock %}
diff --git a/python/van_inventory/templates/meals.html b/python/van_inventory/templates/meals.html
new file mode 100644
index 0000000..10acc50
--- /dev/null
+++ b/python/van_inventory/templates/meals.html
@@ -0,0 +1,15 @@
+{% extends "base.html" %}
+{% block title %}Meals - Van{% endblock %}
+{% block content %}
+Meals
+
+
+
+
+ {% include "partials/meal_rows.html" %}
+
+{% endblock %}
diff --git a/python/van_inventory/templates/partials/ingredient_rows.html b/python/van_inventory/templates/partials/ingredient_rows.html
new file mode 100644
index 0000000..9ac9748
--- /dev/null
+++ b/python/van_inventory/templates/partials/ingredient_rows.html
@@ -0,0 +1,16 @@
+
+
+ | Item | Needed | Have | Unit | |
+
+
+ {% for mi in meal.ingredients %}
+
+ | {{ mi.item.name }} |
+ {{ mi.quantity_needed }} |
+ {{ mi.item.quantity }} |
+ {{ mi.item.unit }} |
+ |
+
+ {% endfor %}
+
+
diff --git a/python/van_inventory/templates/partials/item_rows.html b/python/van_inventory/templates/partials/item_rows.html
new file mode 100644
index 0000000..445e7e1
--- /dev/null
+++ b/python/van_inventory/templates/partials/item_rows.html
@@ -0,0 +1,21 @@
+
+
+ | Name | Qty | Unit | Category | |
+
+
+ {% for item in items %}
+
+ | {{ item.name }} |
+
+
+ |
+ {{ item.unit }} |
+ {{ item.category or "" }} |
+ |
+
+ {% endfor %}
+
+
diff --git a/python/van_inventory/templates/partials/meal_rows.html b/python/van_inventory/templates/partials/meal_rows.html
new file mode 100644
index 0000000..7429b31
--- /dev/null
+++ b/python/van_inventory/templates/partials/meal_rows.html
@@ -0,0 +1,15 @@
+
+
+ | Name | Ingredients | Instructions | |
+
+
+ {% for meal in meals %}
+
+ | {{ meal.name }} |
+ {{ meal.ingredients | length }} |
+ {{ (meal.instructions or "")[:50] }} |
+ |
+
+ {% endfor %}
+
+