Developer Documentation
Architecture Overview
Croft is a monorepo with four packages managed by pnpm workspaces and Turborepo:
| Package | Name | Description |
|---|
| apps/web | @croft/web | React Router v7 on Cloudflare Workers — UI + API |
| packages/db | @croft/db | Drizzle ORM schema, migrations, seed scripts |
| packages/shared | @croft/shared | Shared types, Zod schemas, constants |
| agent | @croft/agent | Meal plan generation scripts, Notion client, API client |
Data Flow
Notion (recipes) ──→ generate-plan script ──→ Croft API ──→ D1 Database
↓
Web App UI
The generate-plan script reads recipes from Notion via the Notion API, syncs them to the database, then generates a deterministic 4-week plan. All data flows through the web app API.
Tech Stack
| Component | Technology |
|---|
| Framework | React Router v7 (SSR on Workers) |
| Database | Cloudflare D1 + Drizzle ORM |
| CSS | Tailwind CSS v4 |
| Drag & drop | @dnd-kit/core (touch-friendly) |
| Push notifications | @block65/webcrypto-web-push |
| Monorepo | pnpm workspaces + Turborepo |
| IDs | ULIDs (time-sortable) |
API Reference
UI Routes (protected by Cloudflare Access in prod)
| Method | Path | Description |
|---|
| GET | /api/preferences | Household members with dietary info |
| POST | /api/meals/search | Search meals by name |
| POST | /api/meal-plans/:id/swap | Swap two meal entries within a week |
| POST | /api/meal-plans/:id/replace | Replace a meal entry with a different meal |
| POST | /api/meal-plans/:id/approve | Approve a week → generates shopping list |
| POST | /api/meal-plans/:id/clear | Clear all entries from a week |
| POST | /api/shopping-list-items/:id | Toggle check/uncheck on a shopping list item |
| GET | /api/chat/unprocessed | Unprocessed chat messages |
| POST | /api/chat/send | Send a chat message (content, contextWeek) |
Push Notification Routes
| Method | Path | Description |
|---|
| GET | /api/push/vapid-key | Returns VAPID public key for client subscription |
| POST | /api/push/subscribe | Save a push subscription (endpoint, keys, deviceName) |
| DELETE | /api/push/subscribe | Remove a push subscription by endpoint |
Calendar Routes
| Method | Path | Description |
|---|
| GET | /api/calendar/connect | Redirects to Google OAuth consent screen |
| GET | /api/calendar/callback | OAuth callback — exchanges code for tokens |
| POST | /api/calendar/disconnect | Clears stored OAuth tokens |
| POST | /api/calendar/select | Select which calendar to use (calendarId) |
| GET | /api/calendar/events?from=&to= | Fetch calendar events for date range |
Agent Routes (Bearer token auth)
All agent routes require Authorization: Bearer <AGENT_API_KEY>.
| Method | Path | Description |
|---|
| GET | /api/agent/cycles | Get current/upcoming cycle |
| POST | /api/agent/cycles | Create a new 4-week cycle (startDate, endDate) |
| POST | /api/agent/meals | Create/update meals with ingredients (deduplicates by name) |
| POST | /api/agent/meal-plans | Create a weekly plan with entries (cycleId, weekStart, entries[]) |
| POST | /api/agent/notifications | Send push notification (type, message) |
| POST | /api/agent/chat/mark-processed | Mark chat messages as processed |
| GET | /api/agent/recent-meals?weeks=N | Meals planned in the last N weeks |
Data Model
Core Tables
meals
| Column | Type | Notes |
|---|
| id | text (PK) | ULID |
| name | text | Meal name |
| notionPageId | text? | Link to Notion recipe |
| description | text? | Short description |
| prepTimeMins | integer? | Prep time in minutes |
| cookTimeMins | integer? | Cook time in minutes |
| difficulty | text? | easy | medium | hard |
| tags | text | JSON array of tags |
| isFavorite | boolean | Default: false |
| lastPlanned | text? | ISO date last used |
meal_plan_cycles
| Column | Type | Notes |
|---|
| id | text (PK) | ULID |
| startDate | text | First Monday (ISO date) |
| endDate | text | Last Sunday (ISO date) |
| status | text | active | completed | archived |
| notes | text? | Cycle notes |
meal_plans
| Column | Type | Notes |
|---|
| id | text (PK) | ULID |
| cycleId | text (FK) | → meal_plan_cycles |
| weekStart | text (unique) | Monday ISO date |
| status | text | draft | pending_review | approved |
| generatedAt | text? | When agent generated |
| approvedAt | text? | When user approved |
| notes | text? | Week notes |
meal_plan_entries
| Column | Type | Notes |
|---|
| id | text (PK) | ULID |
| mealPlanId | text (FK) | → meal_plans |
| dayOfWeek | integer | 0=Sun, 1=Mon, ..., 6=Sat |
| mealType | text | breakfast | lunch | dinner | snack |
| mealId | text? (FK) | → meals (nullable for custom) |
| customName | text? | For custom meal entries |
| sortOrder | integer | Display order |
| notes | text? | Per-entry notes |
Ingredients & Shopping
ingredients
| Column | Type | Notes |
|---|
| id | text (PK) | ULID |
| name | text (unique) | Ingredient name |
| category | text? | produce | dairy | meat | seafood | pantry | frozen | bakery | beverages | condiments | spices | other |
| isStaple | boolean | Excluded from shopping lists when true |
| defaultUnit | text? | Default measurement unit |
meal_ingredients
| Column | Type | Notes |
|---|
| id | text (PK) | ULID |
| mealId | text (FK) | → meals |
| ingredientId | text (FK) | → ingredients |
| quantity | real? | Amount needed |
| unit | text? | Unit of measurement |
| notes | text? | Special notes |
shopping_lists
| Column | Type | Notes |
|---|
| id | text (PK) | ULID |
| mealPlanId | text (FK) | → meal_plans |
shopping_list_items
| Column | Type | Notes |
|---|
| id | text (PK) | ULID |
| shoppingListId | text (FK) | → shopping_lists |
| ingredientId | text? (FK) | → ingredients |
| customName | text? | For custom items |
| quantity | real? | Amount to buy |
| unit | text? | Unit |
| isChecked | boolean | Checked off |
| aisleCategory | text? | Store aisle category |
Other Tables
household_members
| Column | Type | Notes |
|---|
| id | text (PK) | ULID |
| name | text | Member name |
| dietaryRestrictions | text | JSON array (e.g. ["gluten-free"]) |
| preferences | text | JSON: {"likes": [...], "dislikes": [...]} |
| isActive | boolean | Active member |
chat_messages
| Column | Type | Notes |
|---|
| id | text (PK) | ULID |
| role | text | user | assistant | system |
| content | text | Message content |
| contextWeek | text? | ISO date of relevant week |
| isProcessed | boolean | Read by agent |
push_subscriptions
| Column | Type | Notes |
|---|
| id | text (PK) | ULID |
| endpoint | text (unique) | Push service URL |
| p256dh | text | Encryption key |
| auth | text | Auth token |
| deviceName | text? | iPad, iPhone, Browser |
| isActive | boolean | Active subscription |
app_settings
| Column | Type | Notes |
|---|
| key | text (PK) | Setting key |
| value | text | Setting value |
Adding New Modules
Croft is designed to be modular. To add a new assistant module (e.g., chores, fitness):
- Define schema — Add tables in
packages/db/src/schema.ts and generate a migration with drizzle-kit generate - Add shared types — Create types in
packages/shared/src/types/ and export from the index - Build UI routes — Add page routes in
apps/web/app/routes/ with loaders for D1 data - Add API routes — Create resource routes for any UI actions and agent endpoints
- Create agent scripts — Add CLI scripts in
agent/src/cli/ and API client methods in agent/src/services/croft-api.ts - Register routes — Add to
apps/web/app/routes.ts and navigation in root.tsx
D1 Caveats
- No cascade deletes — D1 has a PRAGMA bug with
onDelete: 'cascade' in Drizzle. Handle cascading deletes in application code. - Text-based dates — D1 uses SQLite; store dates as ISO text strings, not native date types.
- ULIDs — All primary keys use ULIDs (time-sortable, no sequences needed).
CLI Reference
Prerequisites
Node 24 is required (managed via nvm). An .nvmrc file is in the repo root.
nvm use # Picks up .nvmrc automatically
Development
# Start all packages in dev mode (uses Turborepo)
pnpm dev
# Run only the web app
pnpm --filter @croft/web dev
# Preview a production build locally
pnpm --filter @croft/web preview
Building
# Build all packages (via Turborepo)
pnpm build
# Build only the web app
pnpm --filter @croft/web build
# Type-check all packages
pnpm typecheck
# Lint all packages
pnpm lint
Database
# Generate a migration after changing schema files
pnpm --filter @croft/db db:generate
# Apply migrations to local D1
pnpm --filter @croft/db db:migrate:local
# Apply migrations to remote (production) D1
pnpm --filter @croft/db db:migrate:remote
# Seed the local database
pnpm --filter @croft/db seed
Deployment
# Deploy the web app to Cloudflare Workers
pnpm --filter @croft/web deploy
# Or manually from the web app directory
cd apps/web && wrangler deploy
# Generate Cloudflare Workers types
pnpm --filter @croft/web cf-typegen
Wrangler
Wrangler commands should be run from apps/web/ where wrangler.jsonc is located.
# Manage secrets (production environment variables)
cd apps/web && wrangler secret put <SECRET_NAME>
# Open the Wrangler dashboard
cd apps/web && wrangler dashboard
# Tail production logs
cd apps/web && wrangler tail
Common Workflows
# Full workflow: schema change → migration → apply
pnpm --filter @croft/db db:generate
pnpm --filter @croft/db db:migrate:local
# Test locally, then deploy:
pnpm --filter @croft/db db:migrate:remote
pnpm --filter @croft/web deploy
Local Development
Vite SSR Gotcha
Workspace packages (@croft/db, @croft/shared) need resolve.alias entries in vite.config.ts pointing to source .ts files. The SSR build can't resolve subpath exports from workspace packages otherwise.