Compare commits
14
Commits
master
...
1f783e5a41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f783e5a41 | ||
|
|
f6510c05ba | ||
|
|
130dc1d501 | ||
|
|
63492445a9 | ||
|
|
68d5b2a45f | ||
|
|
e3f45d1256 | ||
|
|
f3d81d598c | ||
|
|
685cf86819 | ||
|
|
d378842b68 | ||
|
|
21651e1ce8 | ||
|
|
272642f372 | ||
|
|
f0e82ff19f | ||
|
|
551bdc4104 | ||
|
|
deb2c15ab4 |
+4
-4
@@ -31,8 +31,8 @@ Thumbs.db
|
||||
/ui-reference*.html
|
||||
/ui-reference*_files/
|
||||
/ui-reference*.png
|
||||
/screenshots/
|
||||
|
||||
# Local application databases and SQLite sidecars
|
||||
/var/*.sqlite
|
||||
/var/*.sqlite-shm
|
||||
/var/*.sqlite-wal
|
||||
# Local application databases, backups, logs, and SQLite sidecars
|
||||
/var/
|
||||
file.json
|
||||
|
||||
@@ -38,23 +38,46 @@ npm ci
|
||||
python3 -m pip install --user -r requirements-dev.txt
|
||||
```
|
||||
|
||||
## Database
|
||||
## Database & Backups
|
||||
|
||||
SQLite is the canonical writable store. The database is located at
|
||||
`var/recipe-book.sqlite` and is intentionally excluded from Git. The single
|
||||
baseline in `migrations/001_initial.sql` defines its complete schema.
|
||||
`var/recipe-book.sqlite` and is intentionally excluded from Git.
|
||||
|
||||
Create a new local database from the portable culinary dataset:
|
||||
All normal recipe, ingredient, nutrition-mapping, and purchasing changes must
|
||||
be written to SQLite through the application or its validated database
|
||||
functions. This rule also applies to automated and AI-assisted edits. Do not
|
||||
edit `culinary/*.yaml` as a way to update a running application, and do not use
|
||||
unrestricted SQL when `saveRecipeStructure()` or another domain save function
|
||||
is available.
|
||||
|
||||
### Backup and Restore
|
||||
|
||||
To create a full, verifiable JSON backup of the active database:
|
||||
|
||||
```bash
|
||||
npm run db:reset
|
||||
npm run db:backup -- [path/to/backup.json]
|
||||
```
|
||||
|
||||
This command replaces an existing local database. There is intentionally no
|
||||
legacy upgrade chain. YAML under `culinary/` is retained as portable seed and
|
||||
interchange data; normal edits in the management application write to SQLite.
|
||||
To validate a backup bundle without modifying the database:
|
||||
|
||||
```bash
|
||||
npm run db:validate -- path/to/backup.json
|
||||
```
|
||||
|
||||
To restore a backup into SQLite (transactional replace mode):
|
||||
|
||||
```bash
|
||||
npm run db:restore -- path/to/backup.json
|
||||
```
|
||||
|
||||
Backups can also be downloaded and restored interactively through the web UI at `/app/settings/`.
|
||||
|
||||
Generated projections and future YAML/JSON exports flow outward from SQLite.
|
||||
They are suitable for presentation, backup, interchange, and Git review, but
|
||||
must not be edited independently and treated as authoritative.
|
||||
|
||||
See [Local application](docs/local-application.md) for more detail.
|
||||
For moving development to another machine, see [Agent handoff](docs/agent-handoff.md).
|
||||
|
||||
## Development
|
||||
|
||||
@@ -64,6 +87,12 @@ Run the editor:
|
||||
npm run dev:app
|
||||
```
|
||||
|
||||
Ingredient bulk entry uses the local Ollama service through
|
||||
`http://10.0.10.211:11434/api/chat` and the purpose-built
|
||||
`qwen3:4b-instruct` parsing prompt. Override these defaults with
|
||||
`FORMULATION_OLLAMA_URL` and `FORMULATION_INGREDIENT_PARSER_MODEL`. The parser
|
||||
endpoint is disabled whenever `FORMULATION_READ_ONLY=true`.
|
||||
|
||||
Open <http://localhost:4322/app/>.
|
||||
|
||||
To stop any process listening on the application port and start a fresh Astro
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Agent handoff
|
||||
|
||||
## Repository state
|
||||
|
||||
Development happens on `dev`; `master` is the deployable integration branch.
|
||||
Use Node.js 22 or newer and install dependencies with `npm ci`.
|
||||
|
||||
SQLite is the canonical writable store. YAML in `culinary/` is portable seed and
|
||||
interchange data, not the live editing surface. Application and automated edits
|
||||
should use validated domain functions and transactions rather than unrestricted
|
||||
SQL or direct YAML changes.
|
||||
|
||||
## Database Management
|
||||
|
||||
SQLite (`var/recipe-book.sqlite`) is the canonical writable store.
|
||||
Application and automated edits must use validated domain functions and transactions
|
||||
rather than direct YAML changes or unrestricted SQL.
|
||||
|
||||
### Starting the Application
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run dev:app
|
||||
```
|
||||
|
||||
### Backups and Transfers
|
||||
|
||||
The runtime database lives under `var/`, which is intentionally ignored by Git.
|
||||
|
||||
To backup or hand off the current live state:
|
||||
|
||||
```sh
|
||||
# Export full JSON backup
|
||||
npm run db:backup -- var/recipe-book-backup.json
|
||||
|
||||
# Restore database from backup on receiving machine
|
||||
npm run db:restore -- var/recipe-book-backup.json
|
||||
```
|
||||
|
||||
Place the transferred file at `var/recipe-book.sqlite` on the receiving machine.
|
||||
The backup command uses SQLite's online backup API, includes committed WAL data,
|
||||
and refuses to overwrite an existing destination.
|
||||
|
||||
## Validate a change
|
||||
|
||||
```sh
|
||||
scripts/validate-content
|
||||
npm run check:app
|
||||
npm test
|
||||
npm run build:app
|
||||
git diff --check
|
||||
```
|
||||
|
||||
The application supports a read-only deployment with
|
||||
`FORMULATION_READ_ONLY=true`. Ingredient bulk parsing additionally accepts
|
||||
`FORMULATION_OLLAMA_URL` and `FORMULATION_INGREDIENT_PARSER_MODEL`; USDA imports
|
||||
read `USDA_FDC_API_KEY` from the environment.
|
||||
@@ -0,0 +1,320 @@
|
||||
# Formulation API & Model Context Protocol (MCP) Guide
|
||||
|
||||
Formulation exposes two programmatic interfaces for querying, scaling, costing, and manipulating culinary data:
|
||||
1. **Model Context Protocol (MCP) Server**: A standard stdio JSON-RPC server enabling AI assistants (Claude Desktop, Antigravity, Cursor, Gemini) to directly search, retrieve, cost, scale, and save recipes, ingredients, books, and inventory counts.
|
||||
2. **REST Web API (`/api/v1/`)**: HTTP JSON endpoints for external systems, webhooks, and programmatic integrations.
|
||||
|
||||
---
|
||||
|
||||
## 1. Model Context Protocol (MCP) Server
|
||||
|
||||
### Starting the Server
|
||||
```bash
|
||||
npm run mcp
|
||||
# or directly:
|
||||
node scripts/mcp-server.mjs
|
||||
```
|
||||
|
||||
### Client Configuration Examples
|
||||
|
||||
#### Claude Desktop (`%APPDATA%\Claude\claude_desktop_config.json` on Windows / `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"formulation": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"C:\\Users\\nicholas\\Documents\\repos\\formulation\\scripts\\mcp-server.mjs"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Antigravity / Gemini (`.gemini/config/mcp_config.json`)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"formulation": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"scripts/mcp-server.mjs"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Cursor (`.cursor/mcp.json`)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"formulation": {
|
||||
"command": "node",
|
||||
"args": ["scripts/mcp-server.mjs"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MCP Tools Reference (15 Domain Tools)
|
||||
|
||||
| Tool | Parameters | Description |
|
||||
| --- | --- | --- |
|
||||
| `search_recipes` | `query?`, `category?`, `tag?`, `limit?` | Search recipes by keyword, category, or tag |
|
||||
| `get_recipe` | `id`, `scale_factor?`, `target_yield?`, `target_yield_unit?` | Retrieve full recipe formulation with optional scaling |
|
||||
| `save_recipe` | `id?`, `title`, `yield_quantity`, `yield_unit_id`, `components`, `steps`, `notes?` | Create or update a recipe formulation with validation |
|
||||
| `delete_recipe` | `id` | Safely archive a recipe from the active library |
|
||||
| `calculate_recipe_cost` | `recipe_id`, `currency?` | Compute itemized ingredient & sub-recipe cost breakdown |
|
||||
| `calculate_recipe_nutrition` | `recipe_id`, `serving_size_g?` | Compute nutrition facts panel (macros/micros) |
|
||||
| `search_ingredients` | `query?`, `category?`, `missing_cost?`, `limit?` | Search ingredients in the pantry catalog |
|
||||
| `get_ingredient` | `id` | Get ingredient detail with density, conversions, and prices |
|
||||
| `save_ingredient` | `id?`, `name`, `description?`, `categories?`, `tags?`, `aliases?`, `density?` | Create or update an ingredient with density & aliases |
|
||||
| `delete_ingredient` | `id` | Safely archive an ingredient (blocks if used in active recipes) |
|
||||
| `convert_units` | `ingredient_id?`, `quantity`, `from_unit`, `to_unit` | Convert culinary units safely using density data |
|
||||
| `list_inventory_counts` | `status?` (`'all' \| 'open' \| 'completed'`) | List inventory counting sessions |
|
||||
| `get_inventory_count` | `id` | Get full count sheet with locations, items, unit costs, and valuations |
|
||||
| `list_recipe_books` | *(none)* | List recipe books (collections) with recipe counts |
|
||||
| `get_recipe_book` | `id` | Get recipe book details with ordered included recipes |
|
||||
| `save_recipe_book` | `id?`, `name`, `description?`, `recipe_ids?` | Create or update a recipe book / collection |
|
||||
| `export_database_backup` | *(none)* | Export complete JSON backup of all 25 SQLite tables |
|
||||
| `get_database_stats` | *(none)* | Get table entity counts across the database |
|
||||
|
||||
---
|
||||
|
||||
## 2. REST Web API Specification (`/api/v1/`)
|
||||
|
||||
All REST endpoints return standard JSON envelopes:
|
||||
- Success: `{ "success": true, "data": ... }`
|
||||
- Error: `{ "success": false, "error": "Description" }`
|
||||
|
||||
### Recipes
|
||||
|
||||
#### List Recipes
|
||||
```http
|
||||
GET /api/v1/recipes?q=biscotti&limit=10
|
||||
```
|
||||
|
||||
#### Get Recipe Details & Dynamic Scaling
|
||||
```http
|
||||
GET /api/v1/recipes/chocolate_biscotti?scale=2
|
||||
```
|
||||
|
||||
#### Calculate Scaled Quantities
|
||||
```http
|
||||
POST /api/v1/recipes/chocolate_biscotti/scale
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"target_yield": 100,
|
||||
"target_yield_unit": "each"
|
||||
}
|
||||
```
|
||||
|
||||
#### Itemized Cost Rollup
|
||||
```http
|
||||
GET /api/v1/recipes/chocolate_biscotti/cost?currency=USD
|
||||
```
|
||||
|
||||
#### Nutrition Facts Rollup
|
||||
```http
|
||||
GET /api/v1/recipes/chocolate_biscotti/nutrition
|
||||
```
|
||||
|
||||
#### Create Recipe
|
||||
```http
|
||||
POST /api/v1/recipes
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "Vanilla Glaze",
|
||||
"yield_quantity": 250,
|
||||
"yield_unit_id": "gram",
|
||||
"yield_servings": 10,
|
||||
"categories": ["sauce"],
|
||||
"components": [
|
||||
{
|
||||
"name": "Main",
|
||||
"items": [
|
||||
{ "ingredient_id": "confectioners_sugar", "quantity": 200, "unit_id": "gram" },
|
||||
{ "ingredient_id": "milk", "quantity": 45, "unit_id": "gram" },
|
||||
{ "ingredient_id": "vanilla_extract", "quantity": 5, "unit_id": "gram" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{ "instruction": "In a medium bowl, whisk confectioners sugar, milk, and vanilla extract until smooth." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Update Recipe
|
||||
```http
|
||||
PUT /api/v1/recipes/vanilla_glaze
|
||||
```
|
||||
|
||||
#### Archive Recipe
|
||||
```http
|
||||
DELETE /api/v1/recipes/vanilla_glaze
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Ingredients
|
||||
|
||||
#### List Ingredients
|
||||
```http
|
||||
GET /api/v1/ingredients?q=sugar&limit=10
|
||||
```
|
||||
|
||||
#### Get Ingredient Details
|
||||
```http
|
||||
GET /api/v1/ingredients/sugar
|
||||
```
|
||||
|
||||
#### Create Ingredient
|
||||
```http
|
||||
POST /api/v1/ingredients
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Matcha Powder",
|
||||
"description": "Ceremonial Japanese green tea powder",
|
||||
"categories": ["tea", "flavoring"],
|
||||
"tags": ["japanese", "beverage"],
|
||||
"aliases": [{ "name": "Matcha" }],
|
||||
"density": {
|
||||
"mass_quantity": 60,
|
||||
"mass_unit_id": "gram",
|
||||
"volume_quantity": 0.25,
|
||||
"volume_unit_id": "cup_us"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Update Ingredient
|
||||
```http
|
||||
PUT /api/v1/ingredients/matcha_powder
|
||||
```
|
||||
|
||||
#### Archive Ingredient
|
||||
```http
|
||||
DELETE /api/v1/ingredients/matcha_powder
|
||||
```
|
||||
*(Fails safely with HTTP 409 Conflict if ingredient is referenced by active recipes.)*
|
||||
|
||||
---
|
||||
|
||||
### Recipe Books / Collections
|
||||
|
||||
#### List Recipe Books
|
||||
```http
|
||||
GET /api/v1/collections
|
||||
```
|
||||
|
||||
#### Get Recipe Book
|
||||
```http
|
||||
GET /api/v1/collections/baking_essentials
|
||||
```
|
||||
|
||||
#### Create Recipe Book
|
||||
```http
|
||||
POST /api/v1/collections
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Holiday Pastries",
|
||||
"description": "Seasonal baked goods collection",
|
||||
"recipe_ids": ["chocolate_biscotti", "cinnamon_sugar"]
|
||||
}
|
||||
```
|
||||
|
||||
#### Update Recipe Book
|
||||
```http
|
||||
PUT /api/v1/collections/holiday_pastries
|
||||
```
|
||||
|
||||
#### Archive Recipe Book
|
||||
```http
|
||||
DELETE /api/v1/collections/holiday_pastries
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Inventory Counts
|
||||
|
||||
#### List Counting Sessions
|
||||
```http
|
||||
GET /api/v1/inventory/counts?status=all
|
||||
```
|
||||
|
||||
#### Start New Count Session
|
||||
```http
|
||||
POST /api/v1/inventory/counts
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "August End-of-Month Count",
|
||||
"counted_at": "2026-08-31T18:00:00Z",
|
||||
"notes": "Full kitchen and pantry audit",
|
||||
"prepopulate": true
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Count Sheet & Extended Valuation
|
||||
```http
|
||||
GET /api/v1/inventory/counts/count_2026_08_week3
|
||||
```
|
||||
|
||||
#### Save Count Items & Update Status
|
||||
```http
|
||||
PUT /api/v1/inventory/counts/count_2026_08_week3
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "completed",
|
||||
"items": [
|
||||
{ "ingredient_id": "flour_all_purpose", "location_id": "loc_dry_storage", "quantity": 15000, "unit_id": "gram" },
|
||||
{ "ingredient_id": "sugar", "location_id": "loc_dry_storage", "quantity": 25, "unit_id": "pound" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Unit Conversions
|
||||
|
||||
```http
|
||||
POST /api/v1/convert
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"ingredient_id": "salt",
|
||||
"quantity": 2,
|
||||
"from_unit": "tbsp",
|
||||
"to_unit": "gram"
|
||||
}
|
||||
```
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"from": { "quantity": 2, "unit_id": "tbsp" },
|
||||
"to": { "quantity": 36.52, "unit_id": "gram" },
|
||||
"ingredient_id": "salt",
|
||||
"method": "ingredient_measure_conversion"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Database Backups
|
||||
|
||||
```http
|
||||
GET /api/app/backup/export
|
||||
POST /api/app/backup/validate
|
||||
POST /api/app/backup/import?mode=replace|merge
|
||||
```
|
||||
@@ -13,10 +13,11 @@ current canonical state. Names used for search or display belong in ingredient
|
||||
|
||||
## Canonical and derived boundaries
|
||||
|
||||
Canonical records live under `culinary/`. They contain authored or observed
|
||||
Canonical writable records live in SQLite. They include authored or observed
|
||||
facts: recipes, ingredients, measurements, provenance, suppliers, packages,
|
||||
and price observations. Projections may later be generated for another
|
||||
application or database, but they are never canonical.
|
||||
and price observations. YAML under `culinary/` is portable seed/interchange
|
||||
data, while generated site projections and exports are downstream products.
|
||||
Neither is an independently writable source of truth.
|
||||
|
||||
Derived recipe records contain reproducible nutrition, allergen rollups, and
|
||||
costs. They identify the recipe, calculation version, calculation time, and an
|
||||
@@ -129,7 +130,14 @@ truth. Recipes without authored instructions contain one explicit TODO step.
|
||||
Formula-only conversions use a nominal 100 g basis and a
|
||||
theoretical yield until those values are replaced by observed production data.
|
||||
|
||||
Astro reads a generated, read-only SQLite projection and produces static recipe
|
||||
pages. Interactive calculators are small Preact islands supplied with resolved,
|
||||
typed recipe data. Astro and Preact remain presentation consumers; culinary
|
||||
calculations and editing originate in the database and shared calculation tools.
|
||||
Application and agent changes must use validated domain save functions and
|
||||
SQLite transactions. Direct SQL is reserved for schema-aware maintenance where
|
||||
no domain operation exists. Reset/import commands flow from YAML into SQLite and
|
||||
therefore overwrite the current store; export commands flow from SQLite into a
|
||||
portable representation.
|
||||
|
||||
Astro reads SQLite through the application data layer. Generated projections
|
||||
support read-only presentation, and interactive calculators are Preact islands
|
||||
supplied with resolved, typed recipe data. Astro, Preact, and projections remain
|
||||
presentation consumers; culinary calculations and editing originate in SQLite
|
||||
and shared domain tools.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Formulation Documentation & Knowledge Base
|
||||
|
||||
Welcome to the Formulation Help Center. This documentation explains the architecture, business logic, and operational workflows for managing recipes, ingredients, costs, units of measure, inventory, and archives.
|
||||
|
||||
---
|
||||
|
||||
## Knowledge Base Directory
|
||||
|
||||
### 🚀 Getting Started
|
||||
- [Workspace Navigation & Global Search](./getting-started/workspace-navigation.md): Navigating workspaces, filtering catalogs, global search, and keyboard shortcuts.
|
||||
|
||||
### 🍳 Recipes & Formulas
|
||||
- [Scaling, Batching & Yield Calculations](./recipes/scaling-and-yields.md): Interactive scaling, yield conversions, weight-based auto-yields, and portion control.
|
||||
- [Baker's & Standard Percentages](./recipes/bakers-percentages.md): Flour basis calculation, dynamic target weights, and formula ratios.
|
||||
- [Sub-recipes & Prep Methods](./recipes/sub-recipes-and-prep.md): Nesting recipes as ingredients, prep instructions, headers, notes, and equipment tracking.
|
||||
|
||||
### 🌿 Ingredients & Units of Measure
|
||||
- [Units of Measure & Custom Equivalencies](./ingredients/units-and-equivalencies.md): Dimensional systems (mass, volume, count), canonical conversions, density measures, and USDA nutrition mapping.
|
||||
|
||||
### 💰 Costing & Purchasing
|
||||
- [Purchase Items, Pack Sizes & Recipe Costing](./costing/purchase-items-and-costing.md): Invoices, pack configurations, yield factors, price history, food cost per batch, and cost per serving.
|
||||
|
||||
### 📦 Inventory Management
|
||||
- [Inventory Steps for Success (5-Week Implementation Roadmap)](./inventory/inventory-calendar-steps-for-success.md): Full operational guide for going from initial setup to first live period-end inventory count.
|
||||
- [Count Sheets & Storage Locations](./inventory/count-sheets-and-locations.md): Location-specific sheet-to-shelf counting, on-hand inputs, and live extended valuations.
|
||||
|
||||
### 🗄️ Archive & Trash Lifecycle
|
||||
- [Archive & Lifecycle Management](./archive/lifecycle-and-restoration.md): Soft-deletion, catalog filtering, multi-item batch restore, and safe permanent deletion guards.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Archive & Lifecycle Management
|
||||
|
||||
Formulation implements a two-stage deletion lifecycle (Soft Delete $\rightarrow$ Hard Delete) with automated dependency safeguards to protect culinary data integrity.
|
||||
|
||||
---
|
||||
|
||||
## 1. Soft Deletion (Archiving)
|
||||
|
||||
- When an ingredient, recipe, or recipe book is deleted, it is **soft-deleted** (`deleted_at` timestamp recorded) rather than purged immediately.
|
||||
- Archived items are immediately hidden from active searches, auto-complete dropdowns, and directory views.
|
||||
- Active recipes that historically reference an archived ingredient remain intact without breaking calculations.
|
||||
|
||||
---
|
||||
|
||||
## 2. Archive Workspace (`/app/archive/`)
|
||||
|
||||
The Archive workspace allows viewing and managing all removed items:
|
||||
- **Filter by Entity**: Filter by *All*, *Recipes*, *Ingredients*, or *Recipe Books*.
|
||||
- **Multi-Item Batch Selection**: Select multiple items using checkboxes to perform bulk actions.
|
||||
- **Batch Restore**: Instantly restore selected items back to the active catalog.
|
||||
- **Safe Permanent Deletion**:
|
||||
- Permanently purges items from the database.
|
||||
- **Dependency Safeguard**: Formulation automatically verifies whether an item is still referenced by any active recipe or sub-recipe. If dependencies exist, hard deletion is blocked with a clear warning explaining where the item is currently used.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Purchase Items, Pack Sizes & Recipe Costing
|
||||
|
||||
Accurate recipe food costing relies on mapping real-world vendor purchase packages to canonical ingredients.
|
||||
|
||||
---
|
||||
|
||||
## 1. Purchase Items & Pack Configurations
|
||||
|
||||
A **Purchase Item** represents a commercial package purchased from a vendor or distributor:
|
||||
- **Pack Size & Unit**: e.g., `50 lb Bag`, `6 x 1 Gallon Case`, `16 oz Container`.
|
||||
- **Cost**: Total package purchase price (e.g. `$24.50`).
|
||||
- **Yield Factor (%)**: The usable portion percentage after trimming or prep (e.g. 85% usable yield on trimmed beef tenderloin, 100% on flour).
|
||||
- **Unit Cost**: Automatically computed per base unit (e.g. `$0.00108 / gram` or `$0.49 / lb`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Recipe Food Costing Breakdown
|
||||
|
||||
When viewing a recipe's **Cost** tab:
|
||||
1. **Line Cost**: Each ingredient's line item cost is calculated as:
|
||||
$$\text{Line Cost} = \frac{\text{Quantity} \times \text{Unit Cost}}{\text{Yield Factor}}$$
|
||||
2. **Total Batch Cost**: The sum of all line item costs for the batch.
|
||||
3. **Cost per Serving**: Total Batch Cost divided by total yield servings.
|
||||
4. **Food Cost % (Target Selling Price)**:
|
||||
$$\text{Suggested Price} = \frac{\text{Cost per Serving}}{\text{Target Food Cost \%}}$$
|
||||
@@ -0,0 +1,36 @@
|
||||
# Workspace Navigation & Global Search
|
||||
|
||||
Formulation provides a streamlined, fast, centralized directory for managing all culinary data across your operation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Directory Workspace & Workspace Pills
|
||||
|
||||
The home directory (`/app/`) categorizes items into distinct workspaces using top pill badges:
|
||||
|
||||
- **Recipes** (Blue badge): Standalone formulas, prep recipes, and batch formulations.
|
||||
- **Ingredients** (Green badge): Raw culinary ingredients, allergens, density conversions, and supplier links.
|
||||
- **Recipe Books** (Purple badge): Curated collections and menus of recipes (e.g. *Dinner Menu*, *Cocktails*, *Bakery Line*).
|
||||
- **Purchase Items** (Cyan badge): Commercial vendor packages, invoice pack sizes, prices, and vendor SKUs.
|
||||
- **Inventory** (Teal badge): Active and past inventory count sessions with on-hand valuations.
|
||||
- **Archive** (Neutral link): Soft-deleted items ready for restoration or permanent purge.
|
||||
|
||||
---
|
||||
|
||||
## 2. Global Search & Autocompletion
|
||||
|
||||
- **Omnibox Search**: Search across recipe titles, ingredient names, aliases, and purchase items simultaneously.
|
||||
- **Type Filtering**: Narrow search results by specific entity type directly from the search dropdown filter.
|
||||
- **Keyboard Navigation**:
|
||||
- <kbd>Tab</kbd> / <kbd>Arrow Down</kbd>: Highlight matching search candidates.
|
||||
- <kbd>Enter</kbd>: Open the selected recipe or ingredient detail card immediately.
|
||||
- <kbd>Escape</kbd>: Clear search and close active popups.
|
||||
|
||||
---
|
||||
|
||||
## 3. Detail Utility Bar
|
||||
|
||||
Every single item detail page features a fixed top utility bar containing:
|
||||
- **Workspace Breadcrumbs**: Direct navigation back to the active directory workspace.
|
||||
- **Global Search**: Search and jump to other items without returning to the home screen.
|
||||
- **New Action Button (`+ New`)**: Quick creation modal for recipes, ingredients, recipe books, or count sessions from anywhere in the app.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Units of Measure & Custom Equivalencies
|
||||
|
||||
Formulation maintains a rigorous, multi-dimensional unit conversion engine that enforces physical dimensional rules while supporting culinary volume-to-weight equivalencies.
|
||||
|
||||
---
|
||||
|
||||
## 1. Dimensional Systems
|
||||
|
||||
Every unit belongs to a fundamental physical dimension:
|
||||
- **Mass** (Base unit: `gram`): `gram`, `kilogram`, `pound`, `ounce_mass`.
|
||||
- **Volume** (Base unit: `milliliter`): `milliliter`, `liter`, `cup_us` (240 mL legal), `tablespoon_us`, `teaspoon_us`, `fluid_ounce_us`.
|
||||
- **Count** (Base unit: `each`): `each`, `clove`, `head`, `bunch`.
|
||||
- **Temperature** (Affine scale): `fahrenheit`, `celsius`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Density & Ingredient-Specific UoM Equivalencies
|
||||
|
||||
Because ingredients possess different bulk densities (e.g. 1 cup of all-purpose flour = ~120g, whereas 1 cup of honey = ~340g), volume-to-mass conversions require density records.
|
||||
|
||||
### A. UoM Equivalencies Panel
|
||||
- On each ingredient page, the **UoM Equivalency** tab allows defining custom portion measurements:
|
||||
- *Example*: `1 cup = 125 g`
|
||||
- *Example*: `1 medium apple = 182 g`
|
||||
- *Example*: `1 clove garlic = 3 g`
|
||||
|
||||
### B. Resolution Precedence
|
||||
1. **Reviewed Portions / Measures**: Checked first for an exact unit match (e.g. `cup` or `each`).
|
||||
2. **Bulk Density Measurements**: Checked if converting between standard volume and mass dimensions.
|
||||
3. **Canonical Unit Factor**: Applied for within-dimension conversions (e.g. `lb` to `oz`).
|
||||
@@ -0,0 +1,26 @@
|
||||
# Count Sheets & Storage Locations
|
||||
|
||||
Inventory in Formulation is designed for fast, sheet-to-shelf counting across physical kitchen storage locations.
|
||||
|
||||
---
|
||||
|
||||
## 1. Storage Locations
|
||||
|
||||
Organize physical storage areas into logical zones:
|
||||
- **Walk-in Cooler**: Dairy, produce, raw proteins, prepped batch items.
|
||||
- **Dry Storage**: Flours, grains, spices, oils, canned goods.
|
||||
- **Freezer**: Frozen stocks, puff pastry, frozen proteins.
|
||||
- **Bar / Front of House**: Spirits, syrups, mixers, garnishes.
|
||||
- **Line Stations**: Sauté station drawers, prep table bins.
|
||||
|
||||
---
|
||||
|
||||
## 2. Conducting an Inventory Count
|
||||
|
||||
To conduct a count session:
|
||||
1. Go to **Inventory** in the directory toolbar.
|
||||
2. Select **+ New Count Session**.
|
||||
3. In the count session view, select a location filter to display items in shelf order.
|
||||
4. For each line item, enter the on-hand quantity in the **Count** box.
|
||||
5. Review the **Extended Value ($)** column, which automatically computes the value based on current vendor purchase costs.
|
||||
6. Select **Finalize Count** to complete the count and lock the valuation for accounting.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Inventory Steps for Success
|
||||
|
||||
A structured 5-week roadmap to build out recipes, configure purchasing units and costs, organize location-specific count sheets, test inventory counting, and successfully conduct your first live inventory.
|
||||
|
||||
---
|
||||
|
||||
## 5-Week Roadmap Overview
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title Inventory Onboarding & Setup Roadmap
|
||||
dateFormat X
|
||||
axisFormat Day %d
|
||||
section Week 1
|
||||
Recipes & Ingredients Setup :active, 1, 7
|
||||
section Week 2
|
||||
Purchasing Units & Costs :2, 14
|
||||
section Week 3
|
||||
Location Count Sheets :3, 21
|
||||
section Week 4
|
||||
Dry-Run Test Counts :4, 28
|
||||
section Week 5
|
||||
First Live Inventory & Analytics :5, 35
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Week 1: Build Out Recipes & Ingredients Tables
|
||||
|
||||
> **Week 1 Goal**: Your complete recipe database and canonical ingredient list are populated and ready for kitchen use.
|
||||
|
||||
| Day | Action Item | Details & Instructions |
|
||||
|---|---|---|
|
||||
| **Monday** | **Goal Kickoff** | Define the scope of prep items, sub-recipes, and raw ingredients to be tracked. |
|
||||
| **Tuesday** | **Start with Prep Recipes** | Begin by entering your prep recipes and sub-recipes. As you add prep recipes, your canonical ingredient list will automatically populate. |
|
||||
| **Wednesday** | **Audit & Merge Ingredients** | Review your ingredient catalog. Identify duplicates or near-duplicates (e.g. "kosher salt" vs "salt kosher") and merge them into single canonical ingredients. |
|
||||
| **Thursday** | **Duplicate Multi-Type Items** | Make distinct copies of ingredients where you use multiple varieties or grades of the same item (e.g. *Flour - All Purpose* vs *Flour - Bread High Gluten*). |
|
||||
| **Friday** | **Review Kitchen Database** | Verify that recipes have components, steps, and yields properly structured. |
|
||||
| **Saturday & Sunday** | **Milestone Check** | **Look at that!** You now have a complete, standardized recipe database that can be used actively on the kitchen line. |
|
||||
|
||||
---
|
||||
|
||||
## Week 2: Configure Costs & Purchase Units
|
||||
|
||||
> **Week 2 Goal**: All inventoried ingredients have verified purchase packages, unit costs, and yield factors.
|
||||
|
||||
| Day | Action Item | Details & Instructions |
|
||||
|---|---|---|
|
||||
| **Monday** | **Goal Kickoff** | Gather recent supplier invoices, receipts (e.g., Walmart, Sam's Club, US Foods, Sysco), and vendor order guides. |
|
||||
| **Tuesday** | **Invoice Processing & Linking** | Ingest invoice lines into the system to extract package sizes, prices, and vendor SKU codes. |
|
||||
| **Wednesday** | **Manual Costing** | For specialty or local market items without digital invoices, enter package costs manually on the ingredient cost panel. |
|
||||
| **Thursday** | **Spreadsheet Import** | If you maintain vendor price lists in spreadsheets, upload or batch-map purchase packages into your catalog. |
|
||||
| **Friday** | **Map New Purchase Items** | Use the Purchase Items table to map raw invoice line descriptions to their canonical formulation ingredients. |
|
||||
| **Saturday** | **Audit Missing Costs** | Filter your ingredient directory to inspect which items are still unpriced. Add missing package sizes. |
|
||||
| **Sunday** | **Milestone Check** | **Prep recipes now show real costs!** Take a well-deserved break—your recipe costing foundation is complete. |
|
||||
|
||||
---
|
||||
|
||||
## Week 3: Build Location Count Sheets
|
||||
|
||||
> **Week 3 Goal**: Sheet-to-shelf inventory count lists are configured for each physical storage area.
|
||||
|
||||
| Day | Action Item | Details & Instructions |
|
||||
|---|---|---|
|
||||
| **Monday** | **Goal Kickoff** | Identify all physical storage areas across your operation (e.g., *Walk-In Cooler*, *Dry Storage*, *Freezer*, *Bar*, *Line Drawers*). |
|
||||
| **Tuesday** | **Create Count Sheets** | Go to **+ New** and select **Count Sheet**. Ensure count sheets are strictly location-specific. *(Note: Only managers can create count templates).* |
|
||||
| **Wednesday** | **Order "Sheet to Shelf"** | Arrange ingredients in the exact physical order they appear on your shelves (top-to-bottom, left-to-right). This maximizes counting speed and prevents missed items. |
|
||||
| **Thursday** | **Add Ingredients & Batches** | Add raw ingredients (green icon) and prepped batch recipes (blue icon) to each count sheet. Drag and drop to reorder. |
|
||||
| **Friday** | **Set Count Units** | Verify and adjust count units (e.g. *Cases*, *Bags*, *Each*, *Pounds*) to match how cooks physically count each shelf. Count units default to the ingredient's primary purchase unit. |
|
||||
| **Saturday & Sunday** | **Milestone Check** | **Almost there!** All location count sheets are structured and ready for validation. |
|
||||
|
||||
---
|
||||
|
||||
## Week 4: Test Run & Validate Inventory Lists
|
||||
|
||||
> **Week 4 Goal**: Perform a dry-run test count to uncover unit mismatch errors, pack size discrepancies, or missing items.
|
||||
|
||||
| Day | Action Item | Details & Instructions |
|
||||
|---|---|---|
|
||||
| **Monday** | **Goal Kickoff** | Schedule a 20-minute test run with key kitchen leads before service. |
|
||||
| **Tuesday** | **Enter Test Count Values** | Enter a dummy quantity of **`1`** in every column (or enter last month's closing count). Save and submit each location sheet individually (do not submit total final count). |
|
||||
| **Wednesday** | **Export Valuation Report** | Review the calculated on-hand values and line-item totals in the analytics review. |
|
||||
| **Thursday** | **Identify Discrepancies** | Look for extended dollar values that look unusually high or low. This highlights where pack sizes (e.g. $50/case counted as 1 ea = $50 vs $2.08) or count units need calibration. |
|
||||
| **Friday** | **Correct Count Templates** | Update pack sizes, count units, or ingredient equivalencies based on test run findings. |
|
||||
| **Saturday & Sunday** | **Milestone Check** | **Take a deep breath!** Your inventory templates are calibrated, validated, and ready for real operational use. |
|
||||
|
||||
---
|
||||
|
||||
## Week 5: Conduct Your First Live Inventory
|
||||
|
||||
> **Week 5 Goal**: Successfully execute full period-end inventory, capture total valuation, and establish your inventory baseline.
|
||||
|
||||
| Day | Action Item | Details & Instructions |
|
||||
|---|---|---|
|
||||
| **Monday** | **Conduct Live Count** | Assign team members to their respective locations with mobile devices or clipboards. |
|
||||
| **Tuesday** | **Add Items On the Fly** | If an unlisted item is discovered on a shelf during the count, add it on the fly. *(Remember to add it to the master count template afterward).* |
|
||||
| **Wednesday** | **Review & Submit Count** | Once all location lists are filled, managers review pending location totals and submit the total inventory count. |
|
||||
| **Thursday** | **Analyze Inventory Valuation** | Review the total dollar valuation report by storage location and ingredient category. *(Calculations finalize within minutes).* |
|
||||
| **Friday** | **Export Accounting Reports** | Export your finalized inventory valuation breakdown categorized by GL accounting codes for bookkeeping. |
|
||||
| **Saturday & Sunday** | **Celebrate Success!** | You now have a repeatable, accurate, high-speed inventory process embedded into your culinary operations! |
|
||||
@@ -0,0 +1,28 @@
|
||||
# Baker's & Standard Percentages
|
||||
|
||||
In baking and commercial food manufacturing, formulas use **percentages** to ensure recipe scalability and hydration control.
|
||||
|
||||
---
|
||||
|
||||
## 1. Standard Percentage vs. Baker's Percentage
|
||||
|
||||
### Standard % (Total Formulation Basis)
|
||||
$$\text{Standard \%} = \frac{\text{Ingredient Weight}}{\text{Total Batch Weight}} \times 100$$
|
||||
- In Standard Percentage mode, the sum of all ingredient percentages in the recipe equals **100%**.
|
||||
- Use Standard % for confectionery, dressings, beverages, and general culinary batching.
|
||||
|
||||
### Baker's % (Flour / Basis Member Basis)
|
||||
$$\text{Baker's \%} = \frac{\text{Ingredient Weight}}{\text{Total Basis Flour Weight}} \times 100$$
|
||||
- In Baker's Percentage mode, the flour or designated base ingredients are flagged as **Base Members** (`basis_member = true`) and sum to **100%**.
|
||||
- All other ingredients (such as hydration water, salt, yeast, sugar, and butter) are expressed as a percentage relative to the total flour weight (such as 75% hydration water, 2% salt, 1.5% yeast).
|
||||
|
||||
---
|
||||
|
||||
## 2. Using Interactive Percentage Editing
|
||||
|
||||
To configure and edit percentages in a recipe:
|
||||
1. Open the recipe in edit mode.
|
||||
2. Turn on the **Calculate %** toggle.
|
||||
3. Select **Standard %** or **Baker's %**.
|
||||
4. For Baker's %, select the **Base** check box for each flour or grain ingredient.
|
||||
5. In the **%** column, enter the desired percentage for an ingredient. Formulation dynamically calculates the required physical weight and quantity in grams.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Scaling, Batching & Yield Calculations
|
||||
|
||||
Formulation is a weight-first formulation engine designed to scale recipes across commercial batch sizes without calculation rounding drift.
|
||||
|
||||
---
|
||||
|
||||
## 1. Batch Multipliers vs. Yield-Target Scaling
|
||||
|
||||
Recipes can be scaled in two primary modes:
|
||||
|
||||
### A. Batch Multiplier (`x` Factor)
|
||||
To scale by a batch multiplier:
|
||||
1. Open the recipe in view mode or edit mode.
|
||||
2. In the **Batch** field, enter a multiplier value (such as `0.5`, `2`, `5`, or `10`).
|
||||
3. Every ingredient quantity scales proportionally by the exact factor.
|
||||
|
||||
### B. Target Yield Scaling
|
||||
To scale to a specific finished yield target:
|
||||
1. Open the recipe.
|
||||
2. In the **Yield** field, enter the target finished quantity.
|
||||
3. Select the desired yield unit from the unit list.
|
||||
4. Formulation computes the required scale factor based on total recipe weight and updates all ingredient quantities immediately.
|
||||
|
||||
---
|
||||
|
||||
## 2. Weight-Based Auto-Yield Calculation
|
||||
|
||||
To enable automatic total yield calculation:
|
||||
1. Open the recipe in edit mode.
|
||||
2. Turn on the **Auto calculate total yield** toggle.
|
||||
3. Formulation calculates the weight in grams for every ingredient using standard conversion factors or ingredient-specific density measurements.
|
||||
4. The total recipe yield quantity updates automatically to equal the exact sum of all ingredient weights.
|
||||
5. If an ingredient lacks a volume-to-weight equivalency, a notice appears: *"Auto yield excludes N ingredient amounts without a weight equivalency."*
|
||||
|
||||
---
|
||||
|
||||
## 3. Unit Conversion Safety
|
||||
|
||||
- Ingredients measured in mass (such as `g`, `kg`, `oz`, `lb`) convert directly across all mass units.
|
||||
- Ingredients measured in volume (such as `cup`, `tbsp`, `tsp`, `liter`, `ml`) require an ingredient density measurement (such as `1 cup = 120 g`) to convert to mass.
|
||||
- Cross-dimensional conversions without density data are rejected to maintain strict culinary accuracy.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Sub-recipes & Prep Methods
|
||||
|
||||
Recipes in Formulation can nest other recipes as **sub-recipes**, enabling modular batch preparation and accurate cost and nutrition rollup.
|
||||
|
||||
---
|
||||
|
||||
## 1. Using Sub-recipes in Formulations
|
||||
|
||||
To add a sub-recipe to a recipe:
|
||||
1. In the recipe editor, go to the **Formula** section.
|
||||
2. In the ingredient search box, enter the name of the existing recipe.
|
||||
3. From the search results, select the recipe (identified by the blue **Recipe** badge).
|
||||
4. Enter the required quantity and select the unit of measure.
|
||||
|
||||
### Cascading Cost & Nutrition
|
||||
- The sub-recipe's unit cost and nutritional profile are calculated based on its own ingredients and yield, and cascaded into the parent recipe.
|
||||
- Changes to a base sub-recipe (such as *House Mayonnaise*) automatically propagate up to all dishes that include it (such as *Aioli*, *Tartar Sauce*, and *Sandwich Spread*).
|
||||
|
||||
---
|
||||
|
||||
## 2. Structured Prep Method
|
||||
|
||||
The **Prep Method** editor organizes kitchen instructions into ordered, sequential steps.
|
||||
|
||||
### Step-by-Step Instructions
|
||||
1. In the recipe editor, go to the **Prep Method** section.
|
||||
2. Select **Add Step** (or press <kbd>Enter</kbd>) to create a new step.
|
||||
3. Enter the step instructions using imperative action verbs (such as *Combine*, *Preheat*, *Whisk*, or *Bake*).
|
||||
4. To reorder steps, select and drag the 6-dot drag handle to the new position.
|
||||
|
||||
### Section Headings
|
||||
To break complex multi-stage procedures into logical phases:
|
||||
- End the step text with a colon `:` (for example, `To prepare the dough:` or `To bake:`).
|
||||
- Formulation formats these entries as distinct section headers without numbered step bullets.
|
||||
|
||||
### Inline Prep Notes
|
||||
To add non-actionable tips or precautions:
|
||||
- Wrap the text in parentheses `(...)` (for example, `(Note: Chill the dough for at least 30 minutes before rolling.)`).
|
||||
- Formulation formats these entries as italicized notes without numbered step bullets.
|
||||
|
||||
### Bulk Prep Import
|
||||
To import an existing recipe procedure from text:
|
||||
1. In the **Prep Method** header, select **Bulk Add**.
|
||||
2. Paste the multi-line procedure into the text area.
|
||||
3. Select **Import Steps**. Formulation automatically parses section headings, numbered steps, and notes into structured cards.
|
||||
+62
-10
@@ -4,11 +4,30 @@ The local application uses SQLite as its canonical data store. YAML remains a
|
||||
portable import/export format, but normal application saves do not modify it.
|
||||
Derived nutrition and cost are still calculated rather than stored.
|
||||
|
||||
Create the initial database from the current portable dataset with Node 22 or
|
||||
newer, then run either application mode:
|
||||
## Source-of-truth rule
|
||||
|
||||
- SQLite is the only writable source of truth for a running installation.
|
||||
- Humans should edit through the management application.
|
||||
- Automation and AI agents should call validated application commands or domain
|
||||
save functions such as `saveRecipeStructure()`.
|
||||
- Agents should not edit YAML to change live data and should not issue
|
||||
unrestricted SQL when a domain operation exists.
|
||||
- Generated site projections and exports are downstream products of SQLite.
|
||||
|
||||
A safe automated recipe change follows this flow:
|
||||
|
||||
```text
|
||||
agent request
|
||||
-> validate recipe structure and references
|
||||
-> domain save function
|
||||
-> SQLite transaction
|
||||
-> refresh derived projection
|
||||
-> optional explicit export for backup or review
|
||||
```
|
||||
|
||||
Run either application mode with Node 22 or newer:
|
||||
|
||||
```sh
|
||||
npm run db:reset
|
||||
npm run dev:readonly
|
||||
npm run dev:app
|
||||
```
|
||||
@@ -18,11 +37,44 @@ editing controls, and rejects modifying HTTP requests. Browser-side scaling,
|
||||
unit conversion, nutrition, and costing calculations remain available.
|
||||
|
||||
The database is written to `var/recipe-book.sqlite` and is intentionally ignored
|
||||
by Git. `migrations/001_initial.sql` defines the complete relational schema.
|
||||
The application does not support upgrading databases from older schemas: rebuild
|
||||
from portable data with `npm run db:reset`. Recipe edits are transactional and a
|
||||
private save token prevents stale browser tabs from overwriting newer changes.
|
||||
There is no recipe revision history.
|
||||
by Git. Recipe edits are transactional and a private save token prevents stale
|
||||
browser tabs from overwriting newer changes.
|
||||
|
||||
`npm run db:import:yaml` and `npm run db:reset` both replace the database from
|
||||
portable data and are intended for initial setup or an explicit restore.
|
||||
### Backup and Restore
|
||||
|
||||
To export or restore database snapshots across all 25 SQLite tables:
|
||||
|
||||
```sh
|
||||
# Export a JSON backup
|
||||
npm run db:backup -- [path/to/backup.json]
|
||||
|
||||
# Restore database from backup
|
||||
npm run db:restore -- path/to/backup.json
|
||||
```
|
||||
|
||||
Users can also export and restore backups interactively from the web UI at `/app/settings/`.
|
||||
|
||||
## Windows dev-server notes
|
||||
|
||||
Node is installed at `C:\Program Files\nodejs` but is not on the default
|
||||
agent shell PATH. Prefix every npm/npx command:
|
||||
|
||||
```bat
|
||||
cmd /c "set PATH=C:\Program Files\nodejs;%PATH%&& npm run dev:app"
|
||||
```
|
||||
|
||||
`astro dev` runs as a detached daemon (Astro 7). To stop it, find the PID
|
||||
from the port and kill it directly — `scripts/restart-app.mjs` reads `/proc`
|
||||
and does not work on Windows:
|
||||
|
||||
```bat
|
||||
netstat -ano | findstr :4322
|
||||
taskkill /PID <pid> /F /T
|
||||
```
|
||||
|
||||
A long-running dev server inherited from another session can degrade
|
||||
silently: pages render but Preact islands never hydrate (empty
|
||||
`astro-island`, no console error). Before debugging component code, check
|
||||
whether the recipe table hydrates and, if not, restart the dev server. The
|
||||
URL pattern `?astro&type=script` returns 500 even when hydration works — it
|
||||
is not a valid diagnostic.
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# Formulation Recipe Instruction Style Guide
|
||||
|
||||
This style guide establishes procedural writing standards for culinary formulas and kitchen preparation methods in Formulation, adapting the [Microsoft Style Guide for Step-by-Step Instructions](https://learn.microsoft.com/en-us/style-guide/procedures-instructions/writing-step-by-step-instructions) and related procedural principles to commercial culinary workflows.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Principles
|
||||
|
||||
### A. Use Imperative Verb Forms
|
||||
In technical and culinary procedures, readers scan instructions to execute immediate actions. Begin each step with a direct, active verb.
|
||||
|
||||
- **Do**: "In a large bowl, whisk the flour, sugar, and baking powder."
|
||||
- **Do**: "Preheat the deck oven to 450 °F (232 °C)."
|
||||
- **Don't**: "Dry ingredients should be mixed." *(Passive voice)*
|
||||
- **Don't**: "Mixing the dry ingredients." *(Gerund fragment)*
|
||||
- **Don't**: "Next, you will want to whisk the flour..." *(Conversational filler)*
|
||||
|
||||
### B. Place Conditions and Locations First
|
||||
State prerequisites, equipment, or locations before the action so the cook prepares the workstation before executing the step.
|
||||
|
||||
- **Do**: "In the bowl of a stand mixer fitted with the dough hook, combine the water and yeast."
|
||||
- **Do**: "On a lightly floured surface, divide the dough into 8 equal portions."
|
||||
- **Do**: "If the sauce begins to separate, whisk in 1 tablespoon of warm water."
|
||||
- **Don't**: "Combine the water and yeast in the bowl of a stand mixer fitted with the dough hook."
|
||||
- **Don't**: "Divide the dough into 8 equal portions on a lightly floured surface."
|
||||
|
||||
### C. Maintain Parallel Grammatical Structure
|
||||
All steps in a numbered sequence must follow a consistent grammatical pattern.
|
||||
|
||||
- **Do**:
|
||||
1. Combine the dry ingredients in a large bowl.
|
||||
2. Whisk the eggs and milk in a separate pitcher.
|
||||
3. Pour the liquid mixture into the dry ingredients.
|
||||
- **Don't**:
|
||||
1. Combine the dry ingredients.
|
||||
2. Eggs and milk are whisked together.
|
||||
3. Pouring the liquid into dry ingredients.
|
||||
|
||||
### D. Single Action Units
|
||||
Limit each numbered entry to one cohesive operational step. Combine only closely coupled micro-actions occurring at the same station.
|
||||
|
||||
- **Do**:
|
||||
1. Heat the oil in a heavy-bottomed pot over medium-high heat.
|
||||
2. Add the diced onions and cook for 5 minutes, or until translucent.
|
||||
3. Stir in the minced garlic and cook for 1 minute until fragrant.
|
||||
- **Don't**:
|
||||
1. Heat the oil, chop and cook the onions until translucent, then add garlic and cook for 1 minute before pouring in the stock.
|
||||
|
||||
### E. Include Sensory Criteria and Measurable Targets
|
||||
Pair time and temperature measurements with visual, tactile, or olfactory checkpoints.
|
||||
|
||||
- **Do**: "Bake at 375 °F for 45 to 50 minutes, or until a cake tester inserted into the center comes out clean."
|
||||
- **Do**: "Simmer over low heat for 20 minutes, or until the liquid has reduced by half."
|
||||
- **Don't**: "Bake for a while until done."
|
||||
- **Don't**: "Cook for 20 minutes." *(Lacks target consistency cue)*
|
||||
|
||||
---
|
||||
|
||||
## 2. Formatting, Capitalization, and Punctuation
|
||||
|
||||
| Element | Format Rule | Example |
|
||||
|---|---|---|
|
||||
| **Step Sentence** | Capitalize first word; end with a period. | `Transfer the dough to a clean, oiled bowl.` |
|
||||
| **Section Headings** | Sentence case or Title case; must end with a colon `:`. | `For the dough:` / `To bake and finish:` |
|
||||
| **Inline Prep Notes** | Wrapped in parentheses `(...)`; italicized in UI. | `(Note: Dough can be refrigerated for up to 48 hours before baking.)` |
|
||||
| **Temperatures** | Number followed by degree symbol and scale (`°F`, `°C`). | `375 °F (190 °C)` |
|
||||
| **Dimensions & Times** | Standard units with spaces; hyphenated when modifying nouns. | `1/2-inch dice` / `2 to 3 minutes` |
|
||||
| **Critical Control Points (CCP)** | Prefix with `[CCP]` followed by regulatory threshold. | `[CCP] Hold hot at 135 °F (57 °C) or above.` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Sectioning Complex Multi-Stage Recipes
|
||||
|
||||
When a recipe consists of more than 5 to 7 steps, organize the procedure into logical stages using section headers ending with a colon:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- id: step_01
|
||||
order: 1
|
||||
instruction: "For the dough:"
|
||||
- id: step_02
|
||||
order: 2
|
||||
instruction: "In a stand mixer bowl, combine the flour, yeast, and salt."
|
||||
- id: step_03
|
||||
order: 3
|
||||
instruction: "Add the warm water and mix on low speed for 6 minutes."
|
||||
- id: step_04
|
||||
order: 4
|
||||
instruction: "To proof and shape:"
|
||||
- id: step_05
|
||||
order: 5
|
||||
instruction: "Cover the bowl with plastic wrap and let rise for 1 hour at room temperature."
|
||||
- id: step_06
|
||||
order: 6
|
||||
instruction: "Divide the dough into 12 equal rounds and place on a parchment-lined sheet pan."
|
||||
- id: step_07
|
||||
order: 7
|
||||
instruction: "To bake:"
|
||||
- id: step_08
|
||||
order: 8
|
||||
instruction: "Bake at 425 °F for 18 to 20 minutes, or until deep golden brown."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. UI Reference Conventions (Help Docs & Application Copy)
|
||||
|
||||
When writing help articles or in-app instructions describing user interactions with Formulation:
|
||||
|
||||
- **Bold UI Names**: Always bold buttons, fields, tabs, and menu items (e.g. **Save**, **Prep Method**, **Total Yield**).
|
||||
- **Use "Select"**: Use **Select** rather than *Click*, *Click on*, or *Tap*.
|
||||
- **Use "Enter"**: Use **Enter** rather than *Type in* or *Input*.
|
||||
- **Use "Go to"**: Use **Go to** for tab or page navigation (e.g. "Go to **Recipes** > **New Recipe**").
|
||||
- **Avoid UI Jargon**: Avoid referring to *dialog boxes*, *blades*, or *dropdown menus* unless essential for clarity.
|
||||
@@ -0,0 +1,207 @@
|
||||
# meez home page measurements
|
||||
|
||||
Live measurements of the meez recipe home page
|
||||
(`https://app.getmeez.com/home?type=recipe`) used as the styling target for
|
||||
the Formulation app home page. Measured 2026-08-14 with an authenticated
|
||||
relay-browser tab at a 1440×900 viewport, via `getComputedStyle` and
|
||||
`getBoundingClientRect`. Re-measure before trusting these values on a new
|
||||
design pass.
|
||||
|
||||
## Global
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| body background | `#f3f3f3` |
|
||||
| font family | `CircularCustCapNum, sans-serif` (proprietary; Formulation uses the Inter fallback stack) |
|
||||
| base font | 16px / weight 300 / line-height 20px |
|
||||
| text color | `#050841` |
|
||||
|
||||
## Header (sticky top bar)
|
||||
|
||||
- Height 80px, background `#fbfbfb`, no border, no shadow, z-index 1100.
|
||||
- Inner toolbar: padding `0 24px`, min-height 48px.
|
||||
|
||||
### Search box
|
||||
|
||||
- Container: 504 × 40px, background `#fff`, border 1px solid `#f3f3f3`,
|
||||
border-radius 4px, padding `0 12px`.
|
||||
- Icon: 18px, `#8283a0`.
|
||||
- Input: 16px / weight 400, line-height 23px, padding `8px 0 6px`,
|
||||
color `rgba(0,0,0,.87)`; placeholder `#a5a9c1`.
|
||||
|
||||
### New button
|
||||
|
||||
- Height 32px, border-radius 100px, background `#3d5df6`, white text,
|
||||
16px / weight 500, padding `0 12px`.
|
||||
- "add" icon 18px, 8px gap to label.
|
||||
|
||||
### New dropdown
|
||||
|
||||
- Paper: 375px wide, border-radius 4px, no border, MUI elevation-2 shadow:
|
||||
`0 2px 1px -1px rgba(0,0,0,.2), 0 1px 1px 0 rgba(0,0,0,.14), 0 1px 3px 0 rgba(0,0,0,.12)`.
|
||||
- List padding `22px 0`; items min-height 43.4px, padding `8px 25px`.
|
||||
- Item icon: 24px, `#a5a9c1`. Label: 16px / weight 500, `#050841`.
|
||||
- Hover background `#f1f5fe`.
|
||||
|
||||
## Workspace layout
|
||||
|
||||
- Content column 1120px wide, centered; page wrapper padding `0 32px 32px`.
|
||||
- Tabs row starts ≈26px below the header; table header ≈46px below the tabs row.
|
||||
|
||||
### Tab chips (workspace pills)
|
||||
|
||||
- Chip: 40px tall, border-radius 99px, padding `8px 16px 8px 12px`,
|
||||
gap 8px, 16px / weight 400, color `rgba(0,0,0,.87)`.
|
||||
- Inactive: background `#fff`, border 1px solid `#ececec`.
|
||||
- Active: background `#DBE4FF`, no border.
|
||||
- No hover change (hover state identical to resting state).
|
||||
- Icon: 20px circle, white SVG with 1.5px inner padding. Per type:
|
||||
- recipe `#3C4679`
|
||||
- ingredient `#3F908A`
|
||||
- book `#F3A642`
|
||||
- purchase `#3F908A`
|
||||
- Label: 16px / weight 500, `#050841`, 8px right margin.
|
||||
- Count: 13px / weight 500, `#a5a9c1`.
|
||||
|
||||
### Filter chip
|
||||
|
||||
- Transparent background, border-radius 20px, padding 10px.
|
||||
- Funnel SVG 15×18 (viewBox `0 0 18 21`), `#050841`.
|
||||
- Text 15px / weight 400, `#050841`. Border stays transparent on hover.
|
||||
|
||||
## Directory table
|
||||
|
||||
- Header row: 56.66px, background `#fbfbfb`, border-bottom 1px solid
|
||||
`#f3f3f3`, grid gap 8px, no padding; checkbox cell 36px with 8px left padding.
|
||||
- Column titles: 14px / weight 400, `#a5a9c1`, line-height 21px.
|
||||
- Sort icon: 18px, `#a5a9c1`.
|
||||
- Data row: 56.66px, background `#fff`, border-bottom 1px solid `#f3f3f3`,
|
||||
gap 8px, no padding. **No hover background.**
|
||||
- Selected row background `#f1f5fe`.
|
||||
- meez columns: 36 (checkbox) | 112 (type) | 560 (name) | 112 (owner) |
|
||||
224 (last viewed) | 36 (actions). Formulation clones only checkbox, type,
|
||||
name, and actions — Owner/Last Viewed are explicitly out of scope.
|
||||
- Name text: 15px / weight 500, `#050841`, line-height 22.5px. The wrapping
|
||||
link is `#3d5df6` but the name paragraph overrides it.
|
||||
- Row type icon: 24px circle, white 20px SVG, same per-type colors as tabs.
|
||||
- Checkbox: 20×20; unchecked outline `#ececec`, checked fill `#3d5df6`
|
||||
with white check.
|
||||
- Row action button (more_vert): 24×24, `#a5a9c1`, border-radius 50%,
|
||||
hover background `rgba(0,0,0,.04)`.
|
||||
|
||||
### Row action menu
|
||||
|
||||
- Paper: 275px, border-radius 4px, elevation-2 shadow, list padding `8px 0`.
|
||||
- Items: min-height 47.4px, padding `10px 30px`, label 16px / weight 500
|
||||
`#050841`, icon 24px `#a5a9c1`.
|
||||
- Hover `#f1f5fe` (verified with real mouse input). Delete item `#f63d48`.
|
||||
|
||||
### Delete dialog
|
||||
|
||||
- Paper: 700px, border-radius 10px, padding `30px 38px`, MUI elevation-24
|
||||
shadow:
|
||||
`0 11px 15px -7px rgba(0,0,0,.2), 0 24px 38px 3px rgba(0,0,0,.14), 0 9px 46px 8px rgba(0,0,0,.12)`.
|
||||
- Backdrop `rgba(0,0,0,.5)`.
|
||||
- Title: 28px / weight 700, `#202962`, line-height 39px.
|
||||
- Subtitle: 15px / weight 400, `#050841`.
|
||||
- Cancel: transparent, 15px / weight 500, `#050841`, 52px gap to Delete.
|
||||
- Delete: `#f63d48` pill, 16px / weight 500, padding `16px 24px`,
|
||||
min-width 250px, height 48px, border-radius 100px.
|
||||
|
||||
## Dropdown panels (filter / item-type menus)
|
||||
|
||||
- 300px wide, border-radius 4px, no border, elevation-2 shadow,
|
||||
padding `14px 0`.
|
||||
- Labels: 14px; hover `#f1f5fe`. Apply button `#3d5df6`.
|
||||
|
||||
## Measurement cautions
|
||||
|
||||
- MUI hover states (menu items, chips) only appear with real mouse input
|
||||
(CDP mouse events), not synthetic `mouseover` dispatch.
|
||||
- The relay tab cannot screenshot while not visible; use computed styles
|
||||
and rects.
|
||||
- Re-measure on any new pass: these are point-in-time values from one
|
||||
authenticated account's render.
|
||||
|
||||
## Recipe read view (`/recipes/{id}/steps`)
|
||||
|
||||
Measured on the same account (recipe "Marinated Pork Belly"), same viewport
|
||||
rules apply. Formulation's counterpart: `/app/recipes/{id}/` (read mode).
|
||||
|
||||
### Header (48px, background `#fbfbfb`)
|
||||
|
||||
- Breadcrumb links "Home / Recipes": 12px / weight 400, `#202962`,
|
||||
line-height 22.5px; "/" separators `#95969c`, margin 0 4px.
|
||||
- Edit button: white pill, 1px `#3d5df6` border, radius 100px, height 32px,
|
||||
padding 0 12px, text 15px / weight 500 `#3d5df6`, edit icon 16px with 8px
|
||||
right margin.
|
||||
- Share button (not cloned): `#050841` pill, white 16px / weight 500.
|
||||
|
||||
### Layout
|
||||
|
||||
- Body `#f3f3f3`; content wrapper starts 16px below the header.
|
||||
- Two 50/50 columns, both background `#fbfbfb`; right column has a 1px
|
||||
`#f3f3f3` left border. Left padding `0 36px 35px`, right `0 32px 32px`.
|
||||
- meez keeps both columns at 1027px viewport width (stacking breakpoint is
|
||||
lower than Formulation's old 1280px).
|
||||
|
||||
### Left column (formula)
|
||||
|
||||
- Title: 28px / weight 900, `#050841`, line-height 42px; owner line
|
||||
12px / weight 500 directly below.
|
||||
- Batch/yield row: labels 14px / weight 400 `#050841` with 15px right
|
||||
padding; "1x" batch value 14px / weight 500 with `#a5a9c1` underline;
|
||||
yield value 15px / weight 500.
|
||||
- Ingredient table: no header row, no row borders. Component heading rows
|
||||
18px / weight 500, line-height 27px, bottom border `#fdfdfd`. Ingredient
|
||||
rows 40.66px tall, cell padding 8px 0 (name cell +10px left). Quantity
|
||||
number 15px / weight 300, unit 14px. Name link 15px / weight 400,
|
||||
`rgba(0,0,0,.87)`.
|
||||
|
||||
### Right column (tabs + method)
|
||||
|
||||
- Tab bar: 48px, at the top of the column. Tabs: icon 24px in a 45px box
|
||||
(8px padding, 5px right margin), label 13px / weight 400 `#050841`,
|
||||
padding 0 10px 0 0, 1px `#f3f3f3` right border between tabs.
|
||||
Active: background `#f1f5fe` + 1px `#3d5df6` bottom border.
|
||||
- Panel heading row: h2 22px / weight 700, line-height 33px; step count
|
||||
16px / weight 700 `#a5a9c1` with 8px left margin; 34px bottom margin.
|
||||
- Step section headings: 18px / weight 500, line-height 27px.
|
||||
- Steps: number 16px / weight 700, text 16px / weight 300, `#050841`;
|
||||
separators 1px `#f3f3f3` with 16px vertical margins.
|
||||
- Additional details: heading 18px / weight 500; label 14px / weight 500,
|
||||
value 15px / weight 500, `#050841`.
|
||||
|
||||
### Tab icons (Material paths, white-on-inherit)
|
||||
|
||||
- Prep Method: list icon, viewBox 24 (`M4 10.5c-.83 0-1.5.67-1.5 1.5s.67
|
||||
1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5m0-6c-.83 0-1.5.67-1.5
|
||||
1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5m0 12c-.83 0-1.5.68-1.5
|
||||
1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5M7 19h14v-2H7zm0-6h14
|
||||
v-2H7zm0-8v2h14V5z`).
|
||||
- Cost: attach_money icon, viewBox 24.
|
||||
- UoM Equivalency: custom balance icon, viewBox 24.
|
||||
- Nutrition: custom heart icon, viewBox 20×18.
|
||||
### Cost panel (`/recipes/{id}/cost`, measured in the second pass)
|
||||
|
||||
- Panel padding `35px 32px 0` (same `#fbfbfb` column).
|
||||
- h2 22px / weight 700, line-height 33px; description 14px / weight 400
|
||||
`#95969c`; ≈20px gap before the list.
|
||||
- Column header row: padding `0 0 4px 14px`, bottom border `#f3f3f3`,
|
||||
labels 12px / weight 500 `#a5a9c1`; "Expand all | Collapse all" links
|
||||
12px / weight 500 `#3d5df6`.
|
||||
- Rows (MUI accordions): min-height 48px, summary padding `12px 32px`,
|
||||
name 15px / weight 400 `#050841`, cost value 15px / weight 400.
|
||||
Detail labels ("Purchase Item Name", etc.) 14px / weight 400 `#a5a9c1`.
|
||||
|
||||
### UoM Equivalency panel (`/recipes/{id}/equivalency`)
|
||||
|
||||
- h2 "U of M Equivalency" 22px / weight 700, line-height 33px.
|
||||
- Description 14px muted `#95969c`.
|
||||
- Equivalency matrix: three value columns (Weight / Volume / Each) with
|
||||
"=" separators; columns ≈124px wide, 79.5px tall.
|
||||
|
||||
### Nutrition tab
|
||||
|
||||
Gated ("Upgrade" teaser) on the measured account — no read-mode nutrition
|
||||
panel exists in meez to match.
|
||||
@@ -0,0 +1,37 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_locations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_counts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
counted_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_count_items (
|
||||
count_id TEXT NOT NULL REFERENCES inventory_counts(id) ON DELETE CASCADE,
|
||||
location_id TEXT REFERENCES inventory_locations(id),
|
||||
ingredient_id TEXT NOT NULL REFERENCES ingredients(id),
|
||||
quantity REAL NOT NULL,
|
||||
unit_id TEXT NOT NULL REFERENCES units(id),
|
||||
unit_cost REAL,
|
||||
extended_cost REAL,
|
||||
PRIMARY KEY (count_id, location_id, ingredient_id)
|
||||
);
|
||||
|
||||
-- Seed baseline standard locations if table is empty
|
||||
INSERT OR IGNORE INTO inventory_locations (id, name, position, deleted_at) VALUES
|
||||
('loc_walk_in', 'Walk-in Cooler', 1, NULL),
|
||||
('loc_dry_storage', 'Dry Storage', 2, NULL),
|
||||
('loc_freezer', 'Freezer', 3, NULL),
|
||||
('loc_bar', 'Bar & Service', 4, NULL),
|
||||
('loc_line', 'Prep Line', 5, NULL);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Soft delete parity for purchase items
|
||||
ALTER TABLE purchase_items ADD COLUMN deleted_at TEXT;
|
||||
Generated
+904
-389
File diff suppressed because it is too large
Load Diff
+11
-4
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "recipe-book",
|
||||
"name": "formulation",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -19,20 +19,27 @@
|
||||
"start:readonly": "FORMULATION_READ_ONLY=true HOST=127.0.0.1 PORT=4399 node dist/app/server/entry.mjs",
|
||||
"preview:app": "astro preview --config astro.app.config.mjs --port 4322",
|
||||
"test": "vitest run",
|
||||
"db:reset": "node scripts/db-sync.mjs --reset",
|
||||
"db:import:yaml": "node scripts/db-sync.mjs --reset"
|
||||
"db:backup": "node scripts/backup.mjs export",
|
||||
"db:restore": "node scripts/backup.mjs import",
|
||||
"db:validate": "node scripts/backup.mjs validate",
|
||||
"mcp": "node scripts/mcp-server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^11.1.1",
|
||||
"@astrojs/preact": "6.0.2",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"astro": "7.2.1",
|
||||
"preact": "10.29.8",
|
||||
"yaml": "2.9.0"
|
||||
"yaml": "2.9.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "0.9.4",
|
||||
"@types/node": "^22.10.0",
|
||||
"typescript": "5.9.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.2": true
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "..");
|
||||
const databasePath = path.join(root, "var", "recipe-book.sqlite");
|
||||
|
||||
// Dynamically import compiled or source backup engine
|
||||
import { exportDatabase } from "../src/lib/backup/export-database.ts";
|
||||
import { importDatabase } from "../src/lib/backup/import-database.ts";
|
||||
import { validateBackupBundle } from "../src/lib/backup/validate-backup.ts";
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Formulation Database Backup & Restore Tool
|
||||
|
||||
Usage:
|
||||
node scripts/backup.mjs export [output-path.json]
|
||||
node scripts/backup.mjs import <input-path.json> [--replace | --merge]
|
||||
node scripts/backup.mjs validate <input-path.json>
|
||||
|
||||
Commands:
|
||||
export Extracts all 25 SQLite tables into a standardized JSON backup bundle.
|
||||
import Restores or merges a backup bundle into the active SQLite database.
|
||||
validate Checks a backup JSON file for schema integrity without modifying the database.
|
||||
|
||||
Options:
|
||||
--replace (Default for import) Atomically replaces existing database records.
|
||||
--merge Upserts imported records without deleting unmentioned existing data.
|
||||
`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0]?.toLowerCase();
|
||||
|
||||
if (!command || command === "--help" || command === "-h" || command === "help") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "export") {
|
||||
if (!fs.existsSync(databasePath)) {
|
||||
console.error(`Error: Database not found at ${databasePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const db = new DatabaseSync(databasePath);
|
||||
try {
|
||||
const bundle = exportDatabase(db);
|
||||
const defaultName = `formulation-backup-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.json`;
|
||||
const outputPath = args[1] ? path.resolve(process.cwd(), args[1]) : path.join(process.cwd(), defaultName);
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, JSON.stringify(bundle, null, 2), "utf8");
|
||||
console.log(`\n✅ Backup exported successfully to: ${outputPath}`);
|
||||
console.log(` - Recipes: ${bundle.summary.recipes_count}`);
|
||||
console.log(` - Ingredients: ${bundle.summary.ingredients_count}`);
|
||||
console.log(` - Purchase Items: ${bundle.summary.purchase_items_count}`);
|
||||
console.log(` - Collections: ${bundle.summary.collections_count}`);
|
||||
console.log(` - Inventory Counts: ${bundle.summary.inventory_counts_count}`);
|
||||
console.log(` - Total Entities: ${bundle.summary.total_records_count}`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "validate") {
|
||||
const inputPath = args[1];
|
||||
if (!inputPath) {
|
||||
console.error("Error: Please specify the path to a backup JSON file to validate.");
|
||||
process.exit(1);
|
||||
}
|
||||
const resolvedPath = path.resolve(process.cwd(), inputPath);
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
console.error(`Error: File not found: ${resolvedPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const content = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
|
||||
const result = validateBackupBundle(content);
|
||||
|
||||
if (result.valid) {
|
||||
console.log(`\n✅ Backup file '${inputPath}' is valid!`);
|
||||
if (result.summary) {
|
||||
console.log(` - Format Version: ${content.format_version}`);
|
||||
console.log(` - Exported At: ${content.exported_at}`);
|
||||
console.log(` - Recipes: ${result.summary.recipes_count}`);
|
||||
console.log(` - Ingredients: ${result.summary.ingredients_count}`);
|
||||
console.log(` - Purchase Items: ${result.summary.purchase_items_count}`);
|
||||
console.log(` - Inventory Counts: ${result.summary.inventory_counts_count}`);
|
||||
console.log(` - Total Entities: ${result.summary.total_records_count}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`\n❌ Backup validation failed:`);
|
||||
for (const err of result.errors) console.error(` - ${err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "import") {
|
||||
const inputPath = args[1];
|
||||
if (!inputPath) {
|
||||
console.error("Error: Please specify the path to a backup JSON file to import.");
|
||||
process.exit(1);
|
||||
}
|
||||
const resolvedPath = path.resolve(process.cwd(), inputPath);
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
console.error(`Error: File not found: ${resolvedPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mode = args.includes("--merge") ? "merge" : "replace";
|
||||
const content = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
|
||||
|
||||
if (!fs.existsSync(databasePath)) {
|
||||
console.error(`Error: Database not found at ${databasePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = new DatabaseSync(databasePath);
|
||||
try {
|
||||
console.log(`Importing '${inputPath}' into database (mode: ${mode})...`);
|
||||
const result = importDatabase(db, content, { mode, rebuildProjections: true });
|
||||
console.log(`\n✅ ${result.message}`);
|
||||
} catch (err) {
|
||||
console.error(`\n❌ Import failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Error: Unknown command '${command}'`);
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { backup, DatabaseSync } from "node:sqlite";
|
||||
|
||||
const source = path.resolve(process.cwd(), "var", "recipe-book.sqlite");
|
||||
const requestedTarget = process.argv[2];
|
||||
|
||||
if (!requestedTarget) {
|
||||
throw new Error("Usage: npm run db:backup -- /path/to/recipe-book.sqlite");
|
||||
}
|
||||
if (!fs.existsSync(source)) {
|
||||
throw new Error(`Database not found: ${source}`);
|
||||
}
|
||||
|
||||
const target = path.resolve(requestedTarget);
|
||||
if (target === source) {
|
||||
throw new Error("Backup target must differ from the live database.");
|
||||
}
|
||||
if (fs.existsSync(target)) {
|
||||
throw new Error(`Refusing to overwrite existing backup: ${target}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
const database = new DatabaseSync(source, { readOnly: true });
|
||||
try {
|
||||
await backup(database, target);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
console.log(`Backed up ${source} to ${target}`);
|
||||
+15
-4
@@ -8,8 +8,14 @@ import { writeSiteProjection } from "./lib/site-projection.mjs";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "..");
|
||||
const databasePath = path.join(root, "var", "recipe-book.sqlite");
|
||||
if (!process.argv.includes("--reset")) throw new Error("Database initialization replaces the local database. Re-run with --reset.");
|
||||
if (fs.existsSync(databasePath)) fs.rmSync(databasePath);
|
||||
|
||||
console.error(
|
||||
"❌ ERROR: Direct SQLite database resets from culinary YAML files are disabled.\n" +
|
||||
"The SQLite database (var/recipe-book.sqlite) and its JSON backup snapshots (scripts/backup.mjs) are the canonical source of truth.\n" +
|
||||
"To backup the database: npm run db:backup -- [backup.json]\n" +
|
||||
"To restore from backup: npm run db:restore -- <backup.json>\n"
|
||||
);
|
||||
process.exit(1);
|
||||
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
|
||||
const db = new DatabaseSync(databasePath);
|
||||
db.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;");
|
||||
@@ -22,7 +28,12 @@ const records = (directory) => {
|
||||
const run = (sql, values) => db.prepare(sql).run(...values);
|
||||
const json = (value) => JSON.stringify(value ?? []);
|
||||
|
||||
db.exec(fs.readFileSync(path.join(root, "migrations", "001_initial.sql"), "utf8"));
|
||||
const migrationsDir = path.join(root, "migrations");
|
||||
for (const file of fs.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort()) {
|
||||
try {
|
||||
db.exec(fs.readFileSync(path.join(migrationsDir, file), "utf8"));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
@@ -61,7 +72,7 @@ try {
|
||||
JOIN prep_actions a ON a.id = p.action_id WHERE i.ingredient_id IS NOT NULL
|
||||
ON CONFLICT(ingredient_id, action_id) DO NOTHING`);
|
||||
for (const item of records("purchase_items")) {
|
||||
run("INSERT INTO purchase_items VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [item.id, item.ingredient_id, item.name, item.brand ?? null, item.supplier_id ?? null, item.supplier_sku ?? null, item.status, item.package.quantity, item.package.unit_id, item.package.units_per_case ?? 1, item.package.usable_yield_factor ?? 1]);
|
||||
run("INSERT INTO purchase_items(id, ingredient_id, name, brand, supplier_id, supplier_sku, status, package_quantity, package_unit_id, units_per_case, usable_yield_factor, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)", [item.id, item.ingredient_id, item.name, item.brand ?? null, item.supplier_id ?? null, item.supplier_sku ?? null, item.status, item.package.quantity, item.package.unit_id, item.package.units_per_case ?? 1, item.package.usable_yield_factor ?? 1]);
|
||||
for (const price of item.prices) run("INSERT INTO price_observations VALUES (?, ?, ?, ?, ?)", [item.id, price.effective_at, price.currency, price.amount, json(price.source)]);
|
||||
}
|
||||
for (const mapping of records("source_mappings")) run("INSERT INTO source_mappings VALUES (?, ?, ?, ?, ?, ?, ?)", [mapping.id, mapping.subject.type, mapping.subject.id, mapping.mapping_type, mapping.status, json(mapping.source), mapping.nutrition_per_100g ? json(mapping.nutrition_per_100g) : null]);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { dev } from "astro";
|
||||
|
||||
try {
|
||||
const server = await dev({
|
||||
configFile: "astro.app.config.mjs",
|
||||
server: {
|
||||
port: 4322,
|
||||
host: true
|
||||
}
|
||||
});
|
||||
console.log("Astro dev server is running on http://localhost:4322/app/");
|
||||
} catch (err) {
|
||||
console.error("Failed to start Astro dev server:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Meez Design Token & Style Extractor
|
||||
*
|
||||
* Paste this snippet into the DevTools Console while viewing Meez
|
||||
* (e.g. https://app.getmeez.com/home?type=recipe or a recipe detail page).
|
||||
* It will collect computed styles, layout metrics, and SVG icons and copy
|
||||
* a formatted JSON report to your clipboard.
|
||||
*/
|
||||
(() => {
|
||||
const getStyle = (el, prop) => el ? window.getComputedStyle(el).getPropertyValue(prop) : null;
|
||||
|
||||
const extractComponent = (selector, name) => {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return null;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
name,
|
||||
selector,
|
||||
dimensions: { width: rect.width, height: rect.height },
|
||||
typography: {
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
fontWeight: style.fontWeight,
|
||||
lineHeight: style.lineHeight,
|
||||
letterSpacing: style.letterSpacing,
|
||||
color: style.color,
|
||||
},
|
||||
surface: {
|
||||
backgroundColor: style.backgroundColor,
|
||||
borderRadius: style.borderRadius,
|
||||
border: `${style.borderWidth} ${style.borderStyle} ${style.borderColor}`,
|
||||
boxShadow: style.boxShadow,
|
||||
},
|
||||
spacing: {
|
||||
padding: style.padding,
|
||||
margin: style.margin,
|
||||
gap: style.gap,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Collect key UI elements on current page
|
||||
const report = {
|
||||
url: window.location.href,
|
||||
timestamp: new Date().toISOString(),
|
||||
global: {
|
||||
bodyBackground: getStyle(document.body, 'background-color'),
|
||||
fontFamily: getStyle(document.body, 'font-family'),
|
||||
fontSize: getStyle(document.body, 'font-size'),
|
||||
color: getStyle(document.body, 'color'),
|
||||
},
|
||||
components: {
|
||||
header: extractComponent('header, [role="banner"], .MuiAppBar-root', 'Header / Top Bar'),
|
||||
searchBox: extractComponent('input[type="search"], input[placeholder*="Search"]', 'Search Input'),
|
||||
newButton: extractComponent('button:has(svg), a:has(svg)', 'New Button'),
|
||||
tableHeader: extractComponent('[role="rowgroup"] [role="row"]:first-child, thead tr', 'Table Header'),
|
||||
tableRow: extractComponent('[role="rowgroup"] [role="row"]:not(:first-child), tbody tr:first-child', 'Table Data Row'),
|
||||
tabPillActive: extractComponent('.MuiChip-root, [role="tab"][aria-selected="true"]', 'Active Tab / Pill'),
|
||||
},
|
||||
svgIcons: Array.from(document.querySelectorAll('svg')).slice(0, 30).map((svg, idx) => ({
|
||||
index: idx,
|
||||
viewBox: svg.getAttribute('viewBox'),
|
||||
width: svg.getAttribute('width') || svg.clientWidth,
|
||||
height: svg.getAttribute('height') || svg.clientHeight,
|
||||
fill: getStyle(svg, 'fill') || getStyle(svg, 'color'),
|
||||
paths: Array.from(svg.querySelectorAll('path')).map(p => p.getAttribute('d')),
|
||||
ariaLabel: svg.getAttribute('aria-label') || svg.closest('button, a')?.getAttribute('aria-label') || ''
|
||||
}))
|
||||
};
|
||||
|
||||
console.log('=== MEEZ EXTRACTED TOKENS ===', report);
|
||||
const jsonStr = JSON.stringify(report, null, 2);
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(jsonStr).then(() => {
|
||||
console.log('✅ Tokens copied to clipboard!');
|
||||
}).catch(() => {
|
||||
console.log('Copy to clipboard failed. Access report via window.__meezReport');
|
||||
});
|
||||
}
|
||||
window.__meezReport = report;
|
||||
return report;
|
||||
})();
|
||||
@@ -0,0 +1,768 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Advanced Docling Recipe Ingestor for "The Pastry Chef's Little Black Book, Vol. I"
|
||||
*
|
||||
* Supports:
|
||||
* - 2-page facing spreads (Table on Left, Procedure on Right)
|
||||
* - Multi-component formulation splitting (Dough Packet, Butter Packet, Filling, Crust)
|
||||
* - Fractional spoon & unit fallbacks across all columns
|
||||
* - Multi-stage procedure preservation with equipment inference
|
||||
* - Shelf-life & chef's notes extraction
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/ingest-docling-book.mjs --dry-run --pages 26-27
|
||||
* node scripts/ingest-docling-book.mjs --save
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { titleCase } from "../src/lib/format.ts";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "..");
|
||||
const databasePath = path.join(root, "var", "recipe-book.sqlite");
|
||||
const doclingPath = path.join(root, "file.json");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table of Contents Chapter Ranges
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CHAPTER_PAGE_RANGES = [
|
||||
{ name: "Doughs", startPage: 11, endPage: 38, category: "doughs" },
|
||||
{ name: "Tart, Pie & Strudel Fillings", startPage: 39, endPage: 64, category: "tart_pie_fillings" },
|
||||
{ name: "Cakes & Souffles", startPage: 65, endPage: 122, category: "cakes_souffles" },
|
||||
{ name: "Sheet Cakes", startPage: 123, endPage: 166, category: "sheet_cakes" },
|
||||
{ name: "Buttercreams, Frostings & Glazes", startPage: 167, endPage: 190, category: "frostings_glazes" },
|
||||
{ name: "Custards, Creams & Fillings", startPage: 191, endPage: 234, category: "custards_creams" },
|
||||
{ name: "Mousses & Bavarian Creams", startPage: 235, endPage: 296, category: "mousses_bavarians" },
|
||||
{ name: "Cookies & Tuiles", startPage: 297, endPage: 350, category: "cookies_tuiles" },
|
||||
{ name: "Sauces & Poaching Liquids", startPage: 351, endPage: 380, category: "sauces_liquids" },
|
||||
{ name: "Chocolates & Confections", startPage: 381, endPage: 424, category: "confections" },
|
||||
{ name: "Frozen Desserts", startPage: 425, endPage: 472, category: "frozen_desserts" },
|
||||
{ name: "Breakfast", startPage: 473, endPage: 516, category: "breakfast" },
|
||||
{ name: "Breads", startPage: 517, endPage: 537, category: "breads" },
|
||||
];
|
||||
|
||||
export function getChapterForPage(pageNo) {
|
||||
for (const ch of CHAPTER_PAGE_RANGES) {
|
||||
if (pageNo >= ch.startPage && pageNo <= ch.endPage) return ch;
|
||||
}
|
||||
return { name: "General Pastry", category: "pastry" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extended Ingredient Normalization Map
|
||||
// ---------------------------------------------------------------------------
|
||||
const KNOWN_INGREDIENT_MAP = {
|
||||
"butter": "butter",
|
||||
"unsalted butter": "butter",
|
||||
"salted butter": "butter_salted",
|
||||
"clarified butter": "clarified_butter",
|
||||
"brown butter": "brown_butter",
|
||||
"beurre noisette": "brown_butter",
|
||||
"granulated sugar": "sugar",
|
||||
"sugar": "sugar",
|
||||
"powdered sugar": "confectioners_sugar",
|
||||
"confectioners sugar": "confectioners_sugar",
|
||||
"icing sugar": "confectioners_sugar",
|
||||
"brown sugar": "brown_sugar",
|
||||
"light brown sugar": "brown_sugar",
|
||||
"dark brown sugar": "brown_sugar",
|
||||
"all-purpose flour": "flour_all_purpose",
|
||||
"all purpose flour": "flour_all_purpose",
|
||||
"ap flour": "flour_all_purpose",
|
||||
"pastry flour": "flour_pastry",
|
||||
"cake flour": "flour_cake",
|
||||
"bread flour": "flour_bread",
|
||||
"fine whole wheat flour": "flour_whole_wheat",
|
||||
"whole wheat flour": "flour_whole_wheat",
|
||||
"almond flour": "almond_flour",
|
||||
"hazelnut flour": "hazelnut_flour",
|
||||
"whole eggs": "egg_whole",
|
||||
"eggs": "egg_whole",
|
||||
"whole egg": "egg_whole",
|
||||
"egg yolks": "egg_yolk",
|
||||
"egg yolk": "egg_yolk",
|
||||
"egg whites": "egg_whites",
|
||||
"egg white": "egg_whites",
|
||||
"whole milk": "milk_whole",
|
||||
"milk": "milk_whole",
|
||||
"milk powder": "milk_powder",
|
||||
"nonfat dry milk": "milk_powder",
|
||||
"heavy cream": "heavy_cream",
|
||||
"cream": "heavy_cream",
|
||||
"heavy cream 36%": "heavy_cream",
|
||||
"heavy cream 40%": "heavy_cream",
|
||||
"sour cream": "sour_cream",
|
||||
"creme fraiche": "sour_cream",
|
||||
"mascarpone": "mascarpone",
|
||||
"cream cheese": "cream_cheese",
|
||||
"buttermilk": "buttermilk",
|
||||
"salt": "salt",
|
||||
"fine salt": "salt",
|
||||
"kosher salt": "salt",
|
||||
"sea salt": "salt",
|
||||
"baking powder": "baking_powder",
|
||||
"baking soda": "baking_soda",
|
||||
"cream of tartar": "cream_of_tartar",
|
||||
"vanilla extract": "vanilla_extract",
|
||||
"vanilla bean": "vanilla_bean",
|
||||
"vanilla beans": "vanilla_bean",
|
||||
"vanilla paste": "vanilla_extract",
|
||||
"almond extract": "almond_extract",
|
||||
"cinnamon (ground)": "cinnamon",
|
||||
"cinnamon": "cinnamon",
|
||||
"ground cinnamon": "cinnamon",
|
||||
"nutmeg": "nutmeg",
|
||||
"ground nutmeg": "nutmeg",
|
||||
"black pepper": "black_pepper",
|
||||
"white vinegar": "white_vinegar",
|
||||
"vinegar": "white_vinegar",
|
||||
"water": "water",
|
||||
"water (cold)": "water",
|
||||
"water (warm)": "water",
|
||||
"water (hot)": "water",
|
||||
"cocoa powder": "cocoa_powder",
|
||||
"dutch-process cocoa powder": "cocoa_powder",
|
||||
"cocoa butter": "cocoa_butter",
|
||||
"dark chocolate": "chocolate_dark",
|
||||
"chocolate": "chocolate_dark",
|
||||
"dark chocolate 64%": "chocolate_dark",
|
||||
"dark chocolate 70%": "chocolate_dark",
|
||||
"semisweet chocolate": "chocolate_dark",
|
||||
"bittersweet chocolate": "chocolate_dark",
|
||||
"milk chocolate": "chocolate_milk",
|
||||
"white chocolate": "chocolate_white",
|
||||
"cornstarch": "cornstarch",
|
||||
"gelatin (sheet)": "gelatin_sheet",
|
||||
"gelatin (powder)": "gelatin_powder",
|
||||
"gelatin sheets": "gelatin_sheet",
|
||||
"sheet gelatin": "gelatin_sheet",
|
||||
"powdered gelatin": "gelatin_powder",
|
||||
"honey": "honey",
|
||||
"glucose syrup": "glucose_syrup",
|
||||
"glucose": "glucose_syrup",
|
||||
"powdered glucose": "powdered_glucose",
|
||||
"corn syrup": "corn_syrup",
|
||||
"trimoline": "invert_sugar",
|
||||
"invert sugar": "invert_sugar",
|
||||
"canola oil": "canola_oil",
|
||||
"vegetable oil": "canola_oil",
|
||||
"olive oil": "olive_oil",
|
||||
"lemon juice": "lemon_juice",
|
||||
"lemon zest": "lemon_zest",
|
||||
"lemon or lime zest": "lemon_zest",
|
||||
"lime zest": "lime_zest",
|
||||
"lemons": "lemon",
|
||||
"orange juice": "orange_juice",
|
||||
"orange zest": "orange_zest",
|
||||
"lime juice": "lime_juice",
|
||||
"loose tea": "tea_loose",
|
||||
"chopped nuts": "walnut",
|
||||
"passion fruit puree": "passion_fruit_puree",
|
||||
"raspberry puree": "raspberry_puree",
|
||||
"strawberry puree": "strawberry_puree",
|
||||
"mango puree": "mango_puree",
|
||||
"almond paste": "almond_paste",
|
||||
"marzipan": "marzipan",
|
||||
"praline paste": "praline_paste",
|
||||
"hazelnut paste": "hazelnut_paste",
|
||||
"pistachio paste": "pistachio_paste",
|
||||
"walnuts": "walnut",
|
||||
"walnut": "walnut",
|
||||
"pecans": "pecan",
|
||||
"almonds": "almond",
|
||||
"hazelnuts": "hazelnut",
|
||||
"pistachios": "pistachio",
|
||||
"fresh yeast": "yeast_fresh",
|
||||
"yeast (fresh)": "yeast_fresh",
|
||||
"instant yeast": "yeast_instant",
|
||||
"yeast (instant)": "yeast_instant",
|
||||
"active dry yeast": "yeast_active_dry",
|
||||
"ice cream stabilizer": "ice_cream_stabilizer",
|
||||
"sorbet stabilizer": "sorbet_stabilizer",
|
||||
"pectin nh": "pectin_nh",
|
||||
"pectin yellow": "pectin_yellow",
|
||||
"pectin": "pectin",
|
||||
};
|
||||
|
||||
function slugify(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "") || "recipe";
|
||||
}
|
||||
|
||||
function parseMetricAmount(str) {
|
||||
if (!str) return null;
|
||||
const s = str.trim().toLowerCase();
|
||||
|
||||
const kgMatch = s.match(/^([\d.,]+)\s*kg$/i);
|
||||
if (kgMatch) {
|
||||
return { quantity: Math.round(parseFloat(kgMatch[1].replace(/,/g, "")) * 1000 * 100) / 100, unit_id: "gram" };
|
||||
}
|
||||
|
||||
const gMatch = s.match(/^([\d.,]+)\s*g$/i);
|
||||
if (gMatch) {
|
||||
return { quantity: Math.round(parseFloat(gMatch[1].replace(/,/g, "")) * 100) / 100, unit_id: "gram" };
|
||||
}
|
||||
|
||||
const mlMatch = s.match(/^([\d.,]+)\s*ml$/i);
|
||||
if (mlMatch) {
|
||||
return { quantity: Math.round(parseFloat(mlMatch[1].replace(/,/g, "")) * 100) / 100, unit_id: "milliliter" };
|
||||
}
|
||||
|
||||
const lMatch = s.match(/^([\d.,]+)\s*l$/i);
|
||||
if (lMatch) {
|
||||
return { quantity: Math.round(parseFloat(lMatch[1].replace(/,/g, "")) * 1000 * 100) / 100, unit_id: "milliliter" };
|
||||
}
|
||||
|
||||
const numMatch = s.match(/^([\d.,]+)$/);
|
||||
if (numMatch) {
|
||||
return { quantity: Math.round(parseFloat(numMatch[1].replace(/,/g, "")) * 100) / 100, unit_id: "gram" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function cleanFractionText(str) {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.replace(/(\d+)\s*\/\s*\1\s*\/\s*(\d+)/g, (m, a, b) => `${a}/${b}`)
|
||||
.replace(/(\d+)\s*\/\s*(\d+)/g, (m, a, b) => `${a}/${b}`)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseUsFallback(text, name) {
|
||||
if (!text) {
|
||||
if (/zest/i.test(name)) return { quantity: 1, unit_id: "each", notes: "Zest of 1" };
|
||||
if (/vanilla bean/i.test(name)) return { quantity: 1, unit_id: "each", notes: "1 bean" };
|
||||
return { quantity: 1, unit_id: "gram", notes: "To taste / as needed" };
|
||||
}
|
||||
const s = cleanFractionText(text).toLowerCase();
|
||||
if (s.includes("1/8") || s.includes("⅛")) return { quantity: 0.6, unit_id: "gram", notes: "⅛ tsp" };
|
||||
if (s.includes("1/4") || s.includes("¼")) return { quantity: 1.25, unit_id: "gram", notes: "¼ tsp" };
|
||||
if (s.includes("1/2") || s.includes("½")) return { quantity: 2.5, unit_id: "gram", notes: "½ tsp" };
|
||||
if (s.includes("3/4") || s.includes("¾")) return { quantity: 3.75, unit_id: "gram", notes: "¾ tsp" };
|
||||
if (s.includes("1 1/4") || s.includes("1¼")) return { quantity: 6.25, unit_id: "gram", notes: "1¼ tsp" };
|
||||
if (s.includes("1 1/2") || s.includes("1½")) return { quantity: 7.5, unit_id: "gram", notes: "1½ tsp" };
|
||||
if (s.includes("2 t")) return { quantity: 10, unit_id: "gram", notes: "2 tsp" };
|
||||
if (s.includes("1 t") && !s.includes("tbsp")) return { quantity: 5, unit_id: "gram", notes: "1 tsp" };
|
||||
if (s.includes("tbsp") || s.includes("1 t") || s.includes("2 t")) return { quantity: 15, unit_id: "gram", notes: "1 Tbsp" };
|
||||
|
||||
const eachMatch = s.match(/^([\d.]+)\s*(?:each|pc|ea)?$/);
|
||||
if (eachMatch && parseFloat(eachMatch[1]) > 0) return { quantity: parseFloat(eachMatch[1]), unit_id: "each", notes: null };
|
||||
|
||||
const ozMatch = s.match(/^([\d.]+)\s*oz$/);
|
||||
if (ozMatch && parseFloat(ozMatch[1]) > 0) {
|
||||
return { quantity: Math.round(parseFloat(ozMatch[1]) * 28.3495 * 100) / 100, unit_id: "gram", notes: cleanFractionText(text) };
|
||||
}
|
||||
|
||||
return { quantity: 1, unit_id: "gram", notes: cleanFractionText(text) };
|
||||
}
|
||||
|
||||
function cleanIngredientName(raw) {
|
||||
let cleaned = cleanFractionText(raw).replace(/^[\s•\-\*]+/, "");
|
||||
let notes = null;
|
||||
|
||||
const parenMatch = cleaned.match(/^([^(]+)\s*\(([^)]+)\)$/);
|
||||
if (parenMatch) {
|
||||
const baseName = parenMatch[1].trim();
|
||||
const parenContent = parenMatch[2].trim();
|
||||
|
||||
if (/streusel/i.test(baseName)) {
|
||||
if (/zest/i.test(parenContent)) {
|
||||
const fruit = baseName.replace(/streusel/i, "").trim();
|
||||
cleaned = `${fruit} Zest`;
|
||||
notes = `Zest of whole fruit (for ${baseName})`;
|
||||
} else if (/loose tea/i.test(parenContent)) {
|
||||
cleaned = "Loose Tea";
|
||||
notes = `For ${baseName}`;
|
||||
} else if (/chopped/i.test(parenContent)) {
|
||||
const nutType = baseName.replace(/streusel/i, "").trim();
|
||||
cleaned = /nut/i.test(nutType) ? "Chopped Nuts" : (nutType || "Nuts");
|
||||
notes = `${parenContent} (for ${baseName})`;
|
||||
} else {
|
||||
cleaned = baseName;
|
||||
notes = parenContent;
|
||||
}
|
||||
} else {
|
||||
cleaned = baseName;
|
||||
notes = parenContent;
|
||||
}
|
||||
}
|
||||
|
||||
return { name: cleaned, notes };
|
||||
}
|
||||
|
||||
function inferIngredientId(name) {
|
||||
const norm = name.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
if (KNOWN_INGREDIENT_MAP[norm]) return KNOWN_INGREDIENT_MAP[norm];
|
||||
return slugify(norm);
|
||||
}
|
||||
|
||||
function isFlourBasis(ingredientId, name) {
|
||||
const n = (ingredientId + " " + name).toLowerCase();
|
||||
return (
|
||||
n.includes("flour") &&
|
||||
!n.includes("almond") &&
|
||||
!n.includes("hazelnut") &&
|
||||
!n.includes("cornstarch")
|
||||
);
|
||||
}
|
||||
|
||||
function inferEquipment(instruction) {
|
||||
const text = instruction.toLowerCase();
|
||||
const eq = new Set();
|
||||
if (text.includes("mixer") || text.includes("paddle") || text.includes("whip") || text.includes("dough hook")) eq.add("stand_mixer");
|
||||
if (text.includes("whisk")) eq.add("whisk");
|
||||
if (text.includes("bowl")) eq.add("mixing_bowl");
|
||||
if (text.includes("scale") || text.includes("weigh")) eq.add("kitchen_scale");
|
||||
if (text.includes("bake") || text.includes("oven") || text.includes("375°f") || text.includes("350°f") || text.includes("325°f")) eq.add("oven");
|
||||
if (text.includes("sheet pan") || text.includes("parchment") || text.includes("silpat")) eq.add("sheet_pan");
|
||||
if (text.includes("saucepan") || text.includes("simmer") || text.includes("boil") || text.includes("pot")) eq.add("saucepan");
|
||||
if (text.includes("food processor") || text.includes("process") || text.includes("robot coupe")) eq.add("food_processor");
|
||||
if (text.includes("blender") || text.includes("blend") || text.includes("immersion blender")) eq.add("blender");
|
||||
if (text.includes("thermometer") || text.includes("degrees") || text.includes("°c") || text.includes("°f")) eq.add("thermometer");
|
||||
return [...eq];
|
||||
}
|
||||
|
||||
function parseShelfLife(notesList) {
|
||||
for (const note of notesList) {
|
||||
const text = note.toLowerCase();
|
||||
const dayMatch = text.match(/refrigerat\w*\s+for\s+(\d+)\s+days?/i);
|
||||
if (dayMatch) {
|
||||
return {
|
||||
quantity: parseInt(dayMatch[1], 10),
|
||||
unit: "day",
|
||||
storage_condition: "refrigerated",
|
||||
};
|
||||
}
|
||||
const monthMatch = text.match(/frozen\s+(?:up\s+to\s+)?(\d+)\s+months?/i);
|
||||
if (monthMatch) {
|
||||
return {
|
||||
quantity: parseInt(monthMatch[1], 10) * 30,
|
||||
unit: "day",
|
||||
storage_condition: "frozen",
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page & Recipe Extractor
|
||||
// ---------------------------------------------------------------------------
|
||||
export function parseDoclingBook(doclingJson, targetPages = null) {
|
||||
const pagesMap = new Map();
|
||||
|
||||
for (const textNode of doclingJson.texts || []) {
|
||||
const pageNo = textNode.prov?.[0]?.page_no;
|
||||
if (!pageNo) continue;
|
||||
if (targetPages && !targetPages.includes(pageNo)) continue;
|
||||
|
||||
if (!pagesMap.has(pageNo)) pagesMap.set(pageNo, { pageNo, texts: [], tables: [] });
|
||||
pagesMap.get(pageNo).texts.push(textNode);
|
||||
}
|
||||
|
||||
for (const tableNode of doclingJson.tables || []) {
|
||||
const pageNo = tableNode.prov?.[0]?.page_no;
|
||||
if (!pageNo) continue;
|
||||
if (targetPages && !targetPages.includes(pageNo)) continue;
|
||||
|
||||
if (!pagesMap.has(pageNo)) pagesMap.set(pageNo, { pageNo, texts: [], tables: [] });
|
||||
pagesMap.get(pageNo).tables.push(tableNode);
|
||||
}
|
||||
|
||||
const recipes = [];
|
||||
const usedSlugs = new Map();
|
||||
const sortedPages = [...pagesMap.keys()].sort((a, b) => a - b);
|
||||
|
||||
for (const pageNo of sortedPages) {
|
||||
const page = pagesMap.get(pageNo);
|
||||
const chapterInfo = getChapterForPage(pageNo);
|
||||
|
||||
if (page.tables.length === 0) continue;
|
||||
|
||||
for (const table of page.tables) {
|
||||
const cells = table.data?.table_cells || [];
|
||||
if (cells.length < 4) continue;
|
||||
|
||||
const grid = new Map();
|
||||
let maxRow = 0;
|
||||
let maxCol = 0;
|
||||
for (const cell of cells) {
|
||||
const r = cell.start_row_offset_idx;
|
||||
const c = cell.start_col_offset_idx;
|
||||
if (!grid.has(r)) grid.set(r, new Map());
|
||||
grid.get(r).set(c, cell.text?.trim() || "");
|
||||
if (r > maxRow) maxRow = r;
|
||||
if (c > maxCol) maxCol = c;
|
||||
}
|
||||
|
||||
const headerRow = grid.get(0);
|
||||
const isIngredientTable = headerRow && [...headerRow.values()].some((v) => /ingredients/i.test(v));
|
||||
if (!isIngredientTable) continue;
|
||||
|
||||
// Find Recipe Title on this page
|
||||
const titleNode = page.texts.find(
|
||||
(t) => t.label === "section_header" && !/procedure|chef's notes|notes|table of contents|scaling|baking/i.test(t.text)
|
||||
);
|
||||
const title = titleNode ? titleNode.text.trim() : `Recipe Page ${pageNo}`;
|
||||
let slugId = slugify(title);
|
||||
if (usedSlugs.has(slugId)) {
|
||||
const count = usedSlugs.get(slugId) + 1;
|
||||
usedSlugs.set(slugId, count);
|
||||
slugId = `${slugId}_p${pageNo}`;
|
||||
} else {
|
||||
usedSlugs.set(slugId, 1);
|
||||
}
|
||||
|
||||
// Parse ingredients into components
|
||||
const components = [];
|
||||
let currentComponent = { id: "main", name: "Main", items: [] };
|
||||
components.push(currentComponent);
|
||||
|
||||
let totalYieldGrams = null;
|
||||
|
||||
for (let r = 1; r <= maxRow; r++) {
|
||||
const row = grid.get(r);
|
||||
if (!row) continue;
|
||||
|
||||
const ingText = cleanFractionText(row.get(0) || "");
|
||||
const metricText = cleanFractionText(row.get(1) || "");
|
||||
const usText = cleanFractionText(row.get(2) || "");
|
||||
|
||||
if (/total weight/i.test(ingText)) {
|
||||
const parsedTotal = parseMetricAmount(metricText) || parseMetricAmount(usText);
|
||||
if (parsedTotal) totalYieldGrams = parsedTotal.quantity;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ingText) continue;
|
||||
|
||||
// Detect sub-component headers inside tables like "Dough Packet (Détrempe):" or "Filling:"
|
||||
if (ingText.endsWith(":") && !metricText && !usText) {
|
||||
const compName = ingText.replace(/:$/, "").trim();
|
||||
const compSlug = slugify(compName);
|
||||
if (currentComponent.items.length === 0 && components.length === 1) {
|
||||
currentComponent.id = compSlug;
|
||||
currentComponent.name = compName;
|
||||
} else {
|
||||
currentComponent = { id: compSlug, name: compName, items: [] };
|
||||
components.push(currentComponent);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const { name, notes: parenNotes } = cleanIngredientName(ingText);
|
||||
const ingredientId = inferIngredientId(name);
|
||||
|
||||
let parsedMetric = parseMetricAmount(metricText);
|
||||
let notes = parenNotes;
|
||||
let quantity = parsedMetric ? parsedMetric.quantity : 0;
|
||||
let unitId = parsedMetric ? parsedMetric.unit_id : "gram";
|
||||
|
||||
if (quantity <= 0) {
|
||||
const fallback = parseUsFallback(metricText || usText, name);
|
||||
quantity = fallback.quantity;
|
||||
unitId = fallback.unit_id;
|
||||
if (fallback.notes) {
|
||||
notes = notes ? `${notes} (${fallback.notes})` : fallback.notes;
|
||||
}
|
||||
} else if (usText && !notes) {
|
||||
if (/[½¼¾t]/i.test(usText)) {
|
||||
notes = usText;
|
||||
}
|
||||
}
|
||||
|
||||
currentComponent.items.push({
|
||||
raw_name: ingText,
|
||||
clean_name: name,
|
||||
ingredient_id: ingredientId,
|
||||
quantity,
|
||||
unit_id: unitId,
|
||||
us_measure: usText,
|
||||
notes: notes || null,
|
||||
basis_member: isFlourBasis(ingredientId, name),
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out empty components
|
||||
const validComponents = components.filter((c) => c.items.length > 0);
|
||||
if (validComponents.length === 0) continue;
|
||||
|
||||
// Calculate Baker's Percentages across all components
|
||||
const allItems = validComponents.flatMap((c) => c.items);
|
||||
const flourBasisWeight = allItems
|
||||
.filter((i) => i.basis_member)
|
||||
.reduce((sum, i) => sum + i.quantity, 0);
|
||||
|
||||
let itemCounter = 1;
|
||||
const formattedComponents = validComponents.map((comp) => ({
|
||||
id: comp.id,
|
||||
name: comp.name,
|
||||
notes: [],
|
||||
items: comp.items.map((item) => {
|
||||
let pct = null;
|
||||
if (flourBasisWeight > 0 && item.quantity > 0) {
|
||||
pct = Number(((item.quantity / flourBasisWeight) * 100).toFixed(2));
|
||||
}
|
||||
return {
|
||||
id: `line_${String(itemCounter++).padStart(2, "0")}_${item.ingredient_id}`,
|
||||
ingredient_id: item.ingredient_id,
|
||||
name: titleCase(item.clean_name),
|
||||
quantity: item.quantity,
|
||||
unit_id: item.unit_id,
|
||||
percentage: pct,
|
||||
basis_member: item.basis_member,
|
||||
notes: item.notes,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// Find procedure steps on current page or facing spread page (pageNo + 1)
|
||||
const textSources = [...page.texts];
|
||||
const nextPage = pagesMap.get(pageNo + 1);
|
||||
if (nextPage && nextPage.tables.length === 0) {
|
||||
textSources.push(...nextPage.texts);
|
||||
}
|
||||
|
||||
const WORD_TO_NUMBER = {
|
||||
"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10, "dozen": 12, "half": 0.5
|
||||
};
|
||||
|
||||
function parseYieldServings(yieldText) {
|
||||
if (!yieldText) return null;
|
||||
const s = yieldText.replace(/^yields?:\s*/i, "").trim().toLowerCase();
|
||||
const digitMatch = s.match(/^(\d+)/);
|
||||
if (digitMatch) return parseInt(digitMatch[1], 10);
|
||||
const wordMatch = s.match(/^(one|two|three|four|five|six|seven|eight|nine|ten|dozen|half)/);
|
||||
if (wordMatch && WORD_TO_NUMBER[wordMatch[1]]) return WORD_TO_NUMBER[wordMatch[1]];
|
||||
return null;
|
||||
}
|
||||
|
||||
const steps = [];
|
||||
let currentSectionPrefix = "";
|
||||
let inProcedure = false;
|
||||
let inChefNotes = false;
|
||||
let yieldDescription = null;
|
||||
const chefNotesList = [];
|
||||
const narrativeTexts = [];
|
||||
|
||||
for (const textNode of textSources) {
|
||||
const text = textNode.text.trim();
|
||||
|
||||
if (/^yields?:\s*/i.test(text)) {
|
||||
yieldDescription = text;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match procedure section headers like "Dough Packet Procedure:", "Assembly Procedure:", "Procedure:"
|
||||
if (/procedure:?$/i.test(text)) {
|
||||
inProcedure = true;
|
||||
inChefNotes = false;
|
||||
currentSectionPrefix = text.replace(/procedure:?$/i, "").trim();
|
||||
continue;
|
||||
}
|
||||
if (/^chef's notes:?$/i.test(text)) {
|
||||
inProcedure = false;
|
||||
inChefNotes = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inProcedure) {
|
||||
if (textNode.label === "list_item" || textNode.label === "text") {
|
||||
if (!/^\d+$/.test(text)) {
|
||||
const prefix = currentSectionPrefix ? `[${currentSectionPrefix}] ` : "";
|
||||
steps.push({
|
||||
id: `step_${steps.length + 1}`,
|
||||
order: steps.length + 1,
|
||||
instruction: `${prefix}${text}`,
|
||||
equipment_ids: inferEquipment(text),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (inChefNotes) {
|
||||
if (textNode.label === "list_item") {
|
||||
chefNotesList.push(text);
|
||||
} else if (textNode.label === "text" && !/^\d+$/.test(text)) {
|
||||
narrativeTexts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sumWeight = allItems.reduce((s, i) => s + (i.unit_id === "gram" ? i.quantity : 0), 0);
|
||||
const yieldQuantity = totalYieldGrams || (sumWeight > 0 ? sumWeight : 1000);
|
||||
const yieldServings = parseYieldServings(yieldDescription);
|
||||
|
||||
const allNotes = [...chefNotesList];
|
||||
if (yieldDescription) {
|
||||
allNotes.unshift(yieldDescription);
|
||||
}
|
||||
|
||||
const summary = narrativeTexts.length > 0
|
||||
? (yieldDescription ? `${yieldDescription}. ${narrativeTexts.join(" ")}` : narrativeTexts.join(" "))
|
||||
: yieldDescription || null;
|
||||
|
||||
recipes.push({
|
||||
id: slugId,
|
||||
title,
|
||||
page_no: pageNo,
|
||||
chapter: chapterInfo.name,
|
||||
summary,
|
||||
categories: [chapterInfo.category],
|
||||
tags: ["pastry_chefs_little_black_book", chapterInfo.category, "classic"],
|
||||
yield_quantity: Math.round(yieldQuantity * 100) / 100,
|
||||
yield_unit_id: "gram",
|
||||
yield_servings: yieldServings,
|
||||
yield_basis: "theoretical",
|
||||
yield: {
|
||||
quantity: Math.round(yieldQuantity * 100) / 100,
|
||||
unit_id: "gram",
|
||||
servings: yieldServings,
|
||||
basis: "theoretical",
|
||||
},
|
||||
components: formattedComponents,
|
||||
steps: steps.length > 0 ? steps : [{ id: "step_1", order: 1, instruction: "Prepare formulation according to standard pastry method.", equipment_ids: [] }],
|
||||
notes: allNotes,
|
||||
shelf_life: parseShelfLife(chefNotesList),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return recipes;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch Ingestion Runner
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const isSave = args.includes("--save");
|
||||
const isDryRun = args.includes("--dry-run");
|
||||
|
||||
let targetPages = null;
|
||||
const pageIdx = args.indexOf("--page");
|
||||
if (pageIdx !== -1 && args[pageIdx + 1]) {
|
||||
targetPages = [parseInt(args[pageIdx + 1], 10)];
|
||||
}
|
||||
const pagesIdx = args.indexOf("--pages");
|
||||
if (pagesIdx !== -1 && args[pagesIdx + 1]) {
|
||||
const [start, end] = args[pagesIdx + 1].split("-").map((n) => parseInt(n, 10));
|
||||
targetPages = [];
|
||||
for (let p = start; p <= end; p++) targetPages.push(p);
|
||||
}
|
||||
|
||||
console.log("Loading Docling JSON from file.json...");
|
||||
const rawData = fs.readFileSync(doclingPath, "utf8");
|
||||
const doc = JSON.parse(rawData);
|
||||
console.log(`Document loaded: ${doc.texts?.length || 0} texts, ${doc.tables?.length || 0} tables.`);
|
||||
|
||||
const recipes = parseDoclingBook(doc, targetPages);
|
||||
console.log(`\nFound ${recipes.length} formulation(s).`);
|
||||
|
||||
// Detailed inspect for targeted page runs
|
||||
if (targetPages && targetPages.length <= 5) {
|
||||
for (const recipe of recipes) {
|
||||
console.log(`\n================================================================`);
|
||||
console.log(`📖 Page ${recipe.page_no}: ${recipe.title} (${recipe.chapter})`);
|
||||
console.log(` ID: ${recipe.id}`);
|
||||
console.log(` Categories: ${recipe.categories.join(", ")}`);
|
||||
console.log(` Yield: ${recipe.yield.quantity} ${recipe.yield.unit_id} (${recipe.yield.basis})`);
|
||||
if (recipe.summary) console.log(` Summary: ${recipe.summary}`);
|
||||
if (recipe.shelf_life) console.log(` Shelf Life: ${recipe.shelf_life.quantity} ${recipe.shelf_life.unit} (${recipe.shelf_life.storage_condition})`);
|
||||
|
||||
for (const comp of recipe.components) {
|
||||
console.log(`\n Component: [${comp.name}] (${comp.items.length} lines):`);
|
||||
for (const item of comp.items) {
|
||||
const pct = item.percentage !== null ? `(${item.percentage}%)` : "";
|
||||
const basis = item.basis_member ? "[BASIS]" : "";
|
||||
const note = item.notes ? `[${item.notes}]` : "";
|
||||
console.log(` - ${item.name.padEnd(26)} ${String(item.quantity).padStart(5)} ${item.unit_id.padEnd(5)} ${pct.padStart(9)} ${basis} ${note}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n Procedure (${recipe.steps.length} steps):`);
|
||||
for (const step of recipe.steps) {
|
||||
const eq = step.equipment_ids.length > 0 ? ` [Equip: ${step.equipment_ids.join(", ")}]` : "";
|
||||
console.log(` ${step.order}. ${step.instruction}${eq}`);
|
||||
}
|
||||
|
||||
if (recipe.notes.length > 0) {
|
||||
console.log(`\n Chef's Notes:`);
|
||||
for (const n of recipe.notes) console.log(` * ${n}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isSave) {
|
||||
console.log(`\n💾 Ingesting ${recipes.length} recipes into Formulation database...`);
|
||||
const { createMcpTools } = await import("../src/mcp/tools.ts");
|
||||
const { openDatabase, refreshSiteProjection } = await import("../src/lib/database.ts");
|
||||
const db = openDatabase({ readOnly: false });
|
||||
const tools = createMcpTools(() => db);
|
||||
|
||||
try {
|
||||
let newIngredientsCount = 0;
|
||||
const recipeIds = [];
|
||||
|
||||
for (let i = 0; i < recipes.length; i++) {
|
||||
const recipe = recipes[i];
|
||||
|
||||
for (const comp of recipe.components) {
|
||||
for (const item of comp.items) {
|
||||
const exists = db.prepare("SELECT 1 FROM ingredients WHERE id = ?").get(item.ingredient_id);
|
||||
if (!exists) {
|
||||
tools.saveIngredient({
|
||||
id: item.ingredient_id,
|
||||
name: item.name,
|
||||
categories: ["pantry", "baking", "imported_stub"],
|
||||
});
|
||||
newIngredientsCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = tools.saveRecipe(recipe);
|
||||
recipeIds.push(res.recipe_id);
|
||||
|
||||
if ((i + 1) % 50 === 0 || i + 1 === recipes.length) {
|
||||
console.log(` [${i + 1}/${recipes.length}] Processed: ${recipe.title} (${res.recipe_id})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update or create Master Collection
|
||||
if (!targetPages) {
|
||||
const collectionId = "the_pastry_chefs_little_black_book_vol_1";
|
||||
tools.saveRecipeBook({
|
||||
id: collectionId,
|
||||
name: "The Pastry Chef's Little Black Book (Vol. I)",
|
||||
description: "Classic culinary pastry reference by Michael Zebrowski & Michael Mignano (477 formulations across 13 chapters).",
|
||||
recipe_ids: recipeIds,
|
||||
});
|
||||
}
|
||||
|
||||
refreshSiteProjection(db);
|
||||
|
||||
console.log(`\n================================================================`);
|
||||
console.log(`🎉 INGESTION COMPLETE!`);
|
||||
console.log(`================================================================`);
|
||||
console.log(` • Recipes Ingested: ${recipes.length}`);
|
||||
console.log(` • New Ingredients Stubbed: ${newIngredientsCount}`);
|
||||
console.log(` • Site Projection: Refreshed successfully`);
|
||||
} catch (error) {
|
||||
console.error("Ingestion failed:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Ingestion error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import path from "node:path";
|
||||
|
||||
const json = (value, fallback) => { try { return JSON.parse(value); } catch { return fallback; } };
|
||||
|
||||
export const titleCase = (input) => input.split(/\s+/).map((word) => { const index = word.search(/\p{L}/u); return index === -1 ? word : word.slice(0, index) + word[index].toLocaleUpperCase() + word.slice(index + 1); }).join(" ");
|
||||
|
||||
export function createSiteProjection(database) {
|
||||
const units = database.prepare("SELECT * FROM units ORDER BY id").all().map((row) => ({ schema_version:2,id:row.id,name:row.name,symbol:row.symbol,dimension:row.dimension,system:row.system,...(row.base_unit_id?{base_conversion:{base_unit_id:row.base_unit_id,factor:row.factor,...(row.offset!=null?{offset:row.offset}:{})}}:{}) }));
|
||||
const aliasQuery=database.prepare("SELECT name,kind FROM ingredient_aliases WHERE ingredient_id=? ORDER BY name");
|
||||
@@ -12,7 +14,7 @@ export function createSiteProjection(database) {
|
||||
const mappingIdsQuery=database.prepare("SELECT id,mapping_type FROM source_mappings WHERE subject_type='ingredient' AND subject_id=? AND status='reviewed' ORDER BY id");
|
||||
const ingredients = database.prepare("SELECT * FROM ingredients ORDER BY id").all().map((row) => {
|
||||
const mappings=mappingIdsQuery.all(row.id), source=json(row.source_json,"{}");
|
||||
return { schema_version:row.schema_version,id:row.id,name:row.name,...(row.description?{description:row.description}:{}),status:row.status,categories:json(row.categories_json,[]),tags:json(row.tags_json,[]),
|
||||
return { schema_version:row.schema_version,id:row.id,name:titleCase(row.name),...(row.description?{description:row.description}:{}),status:row.status,categories:json(row.categories_json,[]),tags:json(row.tags_json,[]),
|
||||
aliases:aliasQuery.all(row.id),
|
||||
density_measurements:densityQuery.all(row.id).map((value)=>({id:value.id,mass:{quantity:value.mass_quantity,unit_id:value.mass_unit_id},volume:{quantity:value.volume_quantity,unit_id:value.volume_unit_id},...(value.temperature_c!=null?{temperature_c:value.temperature_c}:{}),...(value.state?{state:value.state}:{}),source:json(value.source_json,{})})),
|
||||
measure_conversions:conversionQuery.all(row.id).map((value)=>({id:value.id,from:{quantity:value.from_quantity,unit_id:value.from_unit_id},to:{quantity:value.to_quantity,unit_id:value.to_unit_id},...(value.state?{state:value.state}:{}),source:json(value.source_json,{})})),
|
||||
@@ -46,8 +48,6 @@ export function createSiteProjection(database) {
|
||||
|
||||
export function writeSiteProjection(database, target=path.resolve(process.cwd(),"generated","site-projection.json")) {
|
||||
fs.mkdirSync(path.dirname(target),{recursive:true});
|
||||
const temporary=`${target}.tmp`;
|
||||
fs.writeFileSync(temporary,`${JSON.stringify(createSiteProjection(database))}\n`);
|
||||
fs.renameSync(temporary,target);
|
||||
fs.writeFileSync(target,`${JSON.stringify(createSiteProjection(database))}\n`);
|
||||
return target;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import YAML from "yaml";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "..");
|
||||
const recipesDir = path.join(root, "culinary", "recipes");
|
||||
|
||||
const autofix = process.argv.includes("--fix") || process.argv.includes("--autofix");
|
||||
|
||||
const gerundMap = {
|
||||
"mixing": "Mix",
|
||||
"combining": "Combine",
|
||||
"adding": "Add",
|
||||
"whisking": "Whisk",
|
||||
"stirring": "Stir",
|
||||
"baking": "Bake",
|
||||
"cooking": "Cook",
|
||||
"heating": "Heat",
|
||||
"pouring": "Pour",
|
||||
"placing": "Place",
|
||||
"cutting": "Cut",
|
||||
"dicing": "Dice",
|
||||
"chopping": "Chop",
|
||||
"kneading": "Knead",
|
||||
"rolling": "Roll",
|
||||
"folding": "Fold",
|
||||
"preheating": "Preheat",
|
||||
"seasoning": "Season",
|
||||
"simmering": "Simmer",
|
||||
"boiling": "Boil",
|
||||
"cooling": "Cool",
|
||||
"refrigerating": "Refrigerate",
|
||||
"freezing": "Freeze",
|
||||
"storing": "Store",
|
||||
"serving": "Serve",
|
||||
"garnishing": "Garnish",
|
||||
};
|
||||
|
||||
function standardizeInstruction(text) {
|
||||
let clean = text.trim();
|
||||
if (!clean) return clean;
|
||||
|
||||
// Check if it's an inline note: (Note: ...) or (...)
|
||||
if (clean.startsWith("(") && clean.endsWith(")")) {
|
||||
return clean;
|
||||
}
|
||||
|
||||
// Check if it's a section heading: ends with colon
|
||||
if (clean.endsWith(":")) {
|
||||
// Capitalize first character
|
||||
clean = clean.charAt(0).toUpperCase() + clean.slice(1);
|
||||
return clean;
|
||||
}
|
||||
|
||||
// Check for common gerund starts
|
||||
const words = clean.split(/\s+/);
|
||||
const firstWordLower = words[0].toLowerCase();
|
||||
if (gerundMap[firstWordLower]) {
|
||||
words[0] = gerundMap[firstWordLower];
|
||||
clean = words.join(" ");
|
||||
}
|
||||
|
||||
// Capitalize first letter
|
||||
clean = clean.charAt(0).toUpperCase() + clean.slice(1);
|
||||
|
||||
// Replace trailing comma, semicolon, or dash with period
|
||||
clean = clean.replace(/[,;\-\s]+$/, "");
|
||||
|
||||
// Ensure terminal punctuation if not ending with : or )
|
||||
if (!clean.endsWith(".") && !clean.endsWith("!") && !clean.endsWith("?") && !clean.endsWith(":") && !clean.endsWith(")")) {
|
||||
clean += ".";
|
||||
}
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
function lintRecipeFile(filePath) {
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
const data = YAML.parse(content);
|
||||
if (!data || !Array.isArray(data.steps)) return { warnings: [], errors: [], changed: false };
|
||||
|
||||
const issues = [];
|
||||
let changed = false;
|
||||
|
||||
const newSteps = data.steps.map((step, index) => {
|
||||
const original = step.instruction ?? "";
|
||||
const standardized = standardizeInstruction(original);
|
||||
|
||||
if (original !== standardized) {
|
||||
issues.push({
|
||||
stepOrder: step.order ?? index + 1,
|
||||
original,
|
||||
standardized,
|
||||
});
|
||||
if (autofix) {
|
||||
changed = true;
|
||||
return { ...step, instruction: standardized };
|
||||
}
|
||||
}
|
||||
return step;
|
||||
});
|
||||
|
||||
if (changed && autofix) {
|
||||
data.steps = newSteps;
|
||||
fs.writeFileSync(filePath, YAML.stringify(data, { indent: 2, lineWidth: 0 }), "utf8");
|
||||
}
|
||||
|
||||
return { issues, changed };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const files = fs.readdirSync(recipesDir).filter((f) => f.endsWith(".yaml")).sort();
|
||||
let totalIssues = 0;
|
||||
let filesModified = 0;
|
||||
|
||||
console.log(`Auditing ${files.length} recipe instruction files against Microsoft procedural guidelines...`);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(recipesDir, file);
|
||||
const { issues, changed } = lintRecipeFile(filePath);
|
||||
if (issues.length > 0) {
|
||||
totalIssues += issues.length;
|
||||
if (changed) filesModified++;
|
||||
console.log(`\n📄 ${file} (${issues.length} issue${issues.length > 1 ? "s" : ""}):`);
|
||||
for (const issue of issues) {
|
||||
console.log(` Step ${issue.stepOrder}:`);
|
||||
console.log(` - Current: "${issue.original}"`);
|
||||
console.log(` + Standard: "${issue.standardized}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n--------------------------------------------------");
|
||||
if (autofix) {
|
||||
console.log(`✅ Standardized ${totalIssues} instructions across ${filesModified} recipe files.`);
|
||||
} else {
|
||||
console.log(`Found ${totalIssues} non-standard instructions.`);
|
||||
if (totalIssues > 0) {
|
||||
console.log("Run with --fix to apply automated standardization.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Formulation MCP Server — standalone stdio entry point.
|
||||
*
|
||||
* This script opens the SQLite database directly (bypassing Astro runtime)
|
||||
* and starts the Model Context Protocol server on stdin/stdout.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/mcp-server.mjs
|
||||
* npm run mcp
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { createSiteProjection } from "./lib/site-projection.mjs";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database helpers (standalone, no Astro dependency)
|
||||
// ---------------------------------------------------------------------------
|
||||
const root = path.resolve(import.meta.dirname, "..");
|
||||
const databasePath = path.join(root, "var", "recipe-book.sqlite");
|
||||
|
||||
function openDb(readOnly = true) {
|
||||
if (!fs.existsSync(databasePath)) throw new Error(`Database not found at ${databasePath}`);
|
||||
const db = new DatabaseSync(databasePath, { readOnly });
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
return db;
|
||||
}
|
||||
|
||||
function loadCatalogs() {
|
||||
const db = openDb(true);
|
||||
try {
|
||||
const projection = createSiteProjection(db);
|
||||
const map = (values) => new Map(values.map((v) => [v.id, v]));
|
||||
return {
|
||||
ingredients: map(projection.ingredients),
|
||||
recipes: map(projection.recipes),
|
||||
units: map(projection.units),
|
||||
equipment: map(projection.equipment),
|
||||
prepActions: map(projection.prepActions),
|
||||
purchaseItems: map(projection.purchaseItems),
|
||||
sourceMappings: map(projection.sourceMappings),
|
||||
};
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dynamic imports for domain modules (TypeScript, processed by Node's ESM)
|
||||
// ---------------------------------------------------------------------------
|
||||
const { calculateCost } = await import("../src/lib/costing.ts");
|
||||
const { calculateNutrition } = await import("../src/lib/nutrition.ts");
|
||||
const { convert, convertWithIngredientMeasures } = await import("../src/lib/measurement.ts");
|
||||
const { exportDatabase } = await import("../src/lib/backup/export-database.ts");
|
||||
const { titleCase } = await import("../src/lib/format.ts");
|
||||
const { getInventoryCountDetail, getInventoryCounts } = await import("../src/lib/repository/inventory-repository.ts");
|
||||
const { recipeQualityRows, restoreArchivedItems } = await import("../src/lib/database.ts");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool implementations
|
||||
// ---------------------------------------------------------------------------
|
||||
function searchRecipes(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
const q = (args.query ?? "").trim().toLowerCase();
|
||||
const limit = Math.min(Math.max(args.limit ?? 25, 1), 100);
|
||||
let results = [...catalogs.recipes.values()];
|
||||
|
||||
if (q) results = results.filter((r) => r.title.toLowerCase().includes(q) || r.id.toLowerCase().includes(q) || (r.summary ?? "").toLowerCase().includes(q));
|
||||
if (args.category) results = results.filter((r) => r.categories.includes(args.category));
|
||||
if (args.tag) results = results.filter((r) => (r.tags ?? []).includes(args.tag));
|
||||
|
||||
return results.slice(0, limit).map((r) => ({
|
||||
id: r.id, title: r.title, summary: r.summary ?? null,
|
||||
categories: r.categories, tags: r.tags,
|
||||
yield: { quantity: r.yield.amount.quantity, unit_id: r.yield.amount.unit_id, servings: r.yield.servings ?? null },
|
||||
component_count: r.components.length,
|
||||
item_count: r.components.reduce((n, c) => n + c.items.length, 0),
|
||||
step_count: r.steps.length,
|
||||
}));
|
||||
}
|
||||
|
||||
function getRecipe(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
const recipe = catalogs.recipes.get(args.id);
|
||||
if (!recipe) throw new Error(`Recipe not found: ${args.id}`);
|
||||
|
||||
let sf = 1;
|
||||
if (args.scale_factor > 0) sf = args.scale_factor;
|
||||
else if (args.target_yield > 0 && recipe.yield.amount.quantity > 0) {
|
||||
if (args.target_yield_unit && args.target_yield_unit !== recipe.yield.amount.unit_id) {
|
||||
try { sf = convert(args.target_yield, args.target_yield_unit, recipe.yield.amount.unit_id, catalogs.units) / recipe.yield.amount.quantity; } catch { sf = args.target_yield / recipe.yield.amount.quantity; }
|
||||
} else sf = args.target_yield / recipe.yield.amount.quantity;
|
||||
}
|
||||
|
||||
return {
|
||||
id: recipe.id, title: recipe.title, summary: recipe.summary ?? null,
|
||||
categories: recipe.categories, tags: recipe.tags, station: recipe.station ?? null,
|
||||
yield: { quantity: recipe.yield.amount.quantity * sf, base_quantity: recipe.yield.amount.quantity, unit_id: recipe.yield.amount.unit_id, servings: recipe.yield.servings ? recipe.yield.servings * sf : null, basis: recipe.yield.basis ?? null },
|
||||
scale_factor: sf, scaling: recipe.scaling ?? null,
|
||||
components: recipe.components.map((c) => ({
|
||||
id: c.id, name: c.name, notes: c.notes ?? [],
|
||||
items: c.items.map((item) => {
|
||||
const ref = item.reference;
|
||||
const isSub = "recipe_id" in ref;
|
||||
const sid = isSub ? ref.recipe_id : ref.ingredient_id;
|
||||
const subject = isSub ? catalogs.recipes.get(sid) : catalogs.ingredients.get(sid);
|
||||
return {
|
||||
id: item.id,
|
||||
ingredient_id: isSub ? undefined : sid,
|
||||
subrecipe_id: isSub ? sid : undefined,
|
||||
name: subject ? (isSub ? subject.title : titleCase(subject.name)) : sid,
|
||||
is_subrecipe: isSub,
|
||||
quantity: item.amount.quantity * sf,
|
||||
base_quantity: item.amount.quantity,
|
||||
unit_id: item.amount.unit_id,
|
||||
percentage: item.percentage ?? null,
|
||||
basis_member: item.basis_member ?? false,
|
||||
optional: item.optional ?? false,
|
||||
notes: item.notes ?? null,
|
||||
prep: item.prep ?? [],
|
||||
};
|
||||
}),
|
||||
})),
|
||||
steps: recipe.steps.map((s, i) => ({ id: s.id, order: i + 1, instruction: s.instruction, critical_control_point: s.critical_control_point ?? false, equipment_ids: s.equipment_ids ?? [] })),
|
||||
equipment_ids: recipe.equipment_ids ?? [],
|
||||
notes: recipe.notes ?? [],
|
||||
shelf_life: recipe.shelf_life ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function calcCost(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
const recipe = catalogs.recipes.get(args.recipe_id);
|
||||
if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`);
|
||||
return calculateCost(recipe, { recipes: catalogs.recipes, ingredients: catalogs.ingredients, units: catalogs.units, purchaseItems: catalogs.purchaseItems, prepActions: catalogs.prepActions }, args.currency ?? "USD");
|
||||
}
|
||||
|
||||
function calcNutrition(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
const recipe = catalogs.recipes.get(args.recipe_id);
|
||||
if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`);
|
||||
return calculateNutrition(recipe, { recipes: catalogs.recipes, ingredients: catalogs.ingredients, units: catalogs.units, mappings: catalogs.sourceMappings });
|
||||
}
|
||||
|
||||
function searchIngredients(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
const q = (args.query ?? "").trim().toLowerCase();
|
||||
const limit = Math.min(Math.max(args.limit ?? 25, 1), 100);
|
||||
let results = [...catalogs.ingredients.values()];
|
||||
|
||||
if (q) results = results.filter((ing) => ing.name.toLowerCase().includes(q) || ing.id.toLowerCase().includes(q) || (ing.aliases ?? []).some((a) => a.name.toLowerCase().includes(q)));
|
||||
if (args.category) results = results.filter((ing) => ing.categories.includes(args.category));
|
||||
if (args.missing_cost) {
|
||||
const priced = new Set([...catalogs.purchaseItems.values()].filter((pi) => pi.status === "active").map((pi) => pi.ingredient_id));
|
||||
results = results.filter((ing) => !priced.has(ing.id));
|
||||
}
|
||||
|
||||
return results.slice(0, limit).map((ing) => ({
|
||||
id: ing.id, name: titleCase(ing.name), status: ing.status, categories: ing.categories,
|
||||
alias_count: (ing.aliases ?? []).length,
|
||||
has_nutrition: (ing.nutrition_mapping_ids ?? []).length > 0,
|
||||
has_cost: [...catalogs.purchaseItems.values()].some((pi) => pi.ingredient_id === ing.id && pi.status === "active"),
|
||||
}));
|
||||
}
|
||||
|
||||
function getIngredientDetail(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
const ing = catalogs.ingredients.get(args.id);
|
||||
if (!ing) throw new Error(`Ingredient not found: ${args.id}`);
|
||||
const purchases = [...catalogs.purchaseItems.values()].filter((pi) => pi.ingredient_id === args.id);
|
||||
return {
|
||||
id: ing.id, name: titleCase(ing.name), raw_name: ing.name, status: ing.status,
|
||||
categories: ing.categories, tags: ing.tags ?? [], aliases: ing.aliases ?? [],
|
||||
density_measurements: (ing.density_measurements ?? []).map((d) => ({ id: d.id, mass: d.mass, volume: d.volume, state: d.state ?? null })),
|
||||
measure_conversions: (ing.measure_conversions ?? []).map((c) => ({ id: c.id, from: c.from, to: c.to, state: c.state ?? null })),
|
||||
prep_actions: ing.prep_actions ?? [],
|
||||
nutrition_mapping_ids: ing.nutrition_mapping_ids ?? [],
|
||||
purchase_items: purchases.map((p) => ({ id: p.id, name: p.name, brand: p.brand ?? null, status: p.status, package_quantity: p.package.quantity, package_unit_id: p.package.unit_id, latest_price: p.prices.length > 0 ? p.prices[p.prices.length - 1].amount : null, currency: p.prices.length > 0 ? p.prices[p.prices.length - 1].currency : null })),
|
||||
};
|
||||
}
|
||||
|
||||
function convertUnits(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
const units = catalogs.units;
|
||||
const fromUnit = units.get(args.from_unit);
|
||||
const toUnit = units.get(args.to_unit);
|
||||
if (!fromUnit) throw new Error(`Unknown unit: ${args.from_unit}`);
|
||||
if (!toUnit) throw new Error(`Unknown unit: ${args.to_unit}`);
|
||||
|
||||
let result, method;
|
||||
if (fromUnit.dimension === toUnit.dimension) {
|
||||
result = convert(args.quantity, args.from_unit, args.to_unit, units);
|
||||
method = "dimension_conversion";
|
||||
} else if (args.ingredient_id) {
|
||||
const ing = catalogs.ingredients.get(args.ingredient_id);
|
||||
if (!ing) throw new Error(`Unknown ingredient: ${args.ingredient_id}`);
|
||||
const converted = convertWithIngredientMeasures({ quantity: args.quantity, unit_id: args.from_unit }, args.to_unit, ing, units);
|
||||
result = converted.quantity;
|
||||
method = "ingredient_measure_conversion";
|
||||
} else {
|
||||
throw new Error(`Cannot convert ${fromUnit.dimension} to ${toUnit.dimension} without an ingredient_id for density lookup.`);
|
||||
}
|
||||
return { from: { quantity: args.quantity, unit_id: args.from_unit }, to: { quantity: result, unit_id: args.to_unit }, ingredient_id: args.ingredient_id ?? null, method };
|
||||
}
|
||||
|
||||
function listUnits(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
let units = [...catalogs.units.values()];
|
||||
if (args?.dimension) units = units.filter((u) => u.dimension === args.dimension);
|
||||
if (args?.system) units = units.filter((u) => u.system === args.system);
|
||||
return units;
|
||||
}
|
||||
|
||||
function listEquipment(args) {
|
||||
const catalogs = loadCatalogs();
|
||||
let items = [...catalogs.equipment.values()];
|
||||
if (args?.category) items = items.filter((e) => e.category === args.category);
|
||||
return items;
|
||||
}
|
||||
|
||||
function listPrepActions() {
|
||||
const catalogs = loadCatalogs();
|
||||
return [...catalogs.prepActions.values()];
|
||||
}
|
||||
|
||||
function listPurchaseItems(args) {
|
||||
const db = openDb(true);
|
||||
try {
|
||||
let query = `
|
||||
SELECT p.id, p.ingredient_id, i.name as ingredient_name, p.name, p.brand,
|
||||
p.supplier_id, p.supplier_sku, p.status, p.package_quantity, p.package_unit_id,
|
||||
p.units_per_case, p.usable_yield_factor
|
||||
FROM purchase_items p
|
||||
JOIN ingredients i ON i.id = p.ingredient_id
|
||||
WHERE p.deleted_at IS NULL
|
||||
`;
|
||||
const params = [];
|
||||
if (args.ingredient_id) { query += " AND p.ingredient_id = ?"; params.push(args.ingredient_id); }
|
||||
if (args.status) { query += " AND p.status = ?"; params.push(args.status); }
|
||||
query += " ORDER BY p.name LIMIT ?";
|
||||
params.push(Math.min(Math.max(args.limit ?? 50, 1), 200));
|
||||
|
||||
const rows = db.prepare(query).all(...params);
|
||||
const priceStmt = db.prepare("SELECT amount, currency, effective_at FROM price_observations WHERE purchase_item_id = ? ORDER BY effective_at DESC LIMIT 1");
|
||||
return rows.map((r) => {
|
||||
const p = priceStmt.get(r.id);
|
||||
return { ...r, ingredient_name: titleCase(r.ingredient_name), latest_price: p?.amount ?? null, currency: p?.currency ?? null };
|
||||
});
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
function getDatabaseStats() {
|
||||
const db = openDb(true);
|
||||
try {
|
||||
const c = (t) => db.prepare(`SELECT count(*) as c FROM ${t}`).get().c;
|
||||
return { recipes: c("recipes"), ingredients: c("ingredients"), units: c("units"), equipment: c("equipment"), purchase_items: c("purchase_items"), collections: c("collections"), inventory_counts: c("inventory_counts") };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
function listRecipeBooks() {
|
||||
const db = openDb(true);
|
||||
try {
|
||||
return db.prepare(
|
||||
`SELECT c.id, c.name, c.description, COUNT(cr.recipe_id) as recipe_count
|
||||
FROM collections c LEFT JOIN collection_recipes cr ON cr.collection_id = c.id
|
||||
WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.name`
|
||||
).all();
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
function getRecipeBook(args) {
|
||||
const db = openDb(true);
|
||||
try {
|
||||
const book = db.prepare("SELECT id, name, description FROM collections WHERE id = ? AND deleted_at IS NULL").get(args.id);
|
||||
if (!book) throw new Error(`Recipe book not found: ${args.id}`);
|
||||
const recipes = db.prepare(
|
||||
`SELECT r.id, r.title, r.summary, cr.position
|
||||
FROM collection_recipes cr JOIN recipes r ON r.id = cr.recipe_id
|
||||
WHERE cr.collection_id = ? AND r.deleted_at IS NULL ORDER BY cr.position, r.title`
|
||||
).all(args.id);
|
||||
return { ...book, recipes };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
function auditQuality() {
|
||||
const db = openDb(true);
|
||||
try {
|
||||
const rows = recipeQualityRows(db);
|
||||
const clean = rows.filter((r) => r.placeholder_steps === 0 && r.unpriced_items === 0 && r.yield_basis !== null).length;
|
||||
return { summary: { total: rows.length, clean, needs_attention: rows.length - clean }, recipes: rows };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP Server setup
|
||||
// ---------------------------------------------------------------------------
|
||||
const server = new McpServer({ name: "formulation", version: "2.0.0" });
|
||||
|
||||
function wrap(fn) {
|
||||
return async (args) => {
|
||||
try {
|
||||
const result = fn(args);
|
||||
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
server.tool("search_recipes", "Search recipes by keyword, category, or tag.", { query: z.string().optional(), category: z.string().optional(), tag: z.string().optional(), limit: z.number().int().min(1).max(100).default(25) }, wrap(searchRecipes));
|
||||
server.tool("get_recipe", "Get full recipe formulation with optional scaling.", { id: z.string(), scale_factor: z.number().positive().optional(), target_yield: z.number().positive().optional(), target_yield_unit: z.string().optional() }, wrap(getRecipe));
|
||||
server.tool("audit_recipe_quality", "Run automated quality audit on all recipes.", {}, wrap(auditQuality));
|
||||
server.tool("calculate_recipe_cost", "Compute itemized cost breakdown for a recipe.", { recipe_id: z.string(), currency: z.string().default("USD") }, wrap(calcCost));
|
||||
server.tool("calculate_recipe_nutrition", "Calculate nutrition facts per 100g and per serving.", { recipe_id: z.string(), serving_size_g: z.number().positive().optional() }, wrap(calcNutrition));
|
||||
server.tool("search_ingredients", "Search ingredients with cost/nutrition status.", { query: z.string().optional(), category: z.string().optional(), missing_cost: z.boolean().optional(), limit: z.number().int().min(1).max(100).default(25) }, wrap(searchIngredients));
|
||||
server.tool("get_ingredient", "Get ingredient detail with density, equivalencies, and prices.", { id: z.string() }, wrap(getIngredientDetail));
|
||||
server.tool("list_purchase_items", "List purchase items with current prices.", { ingredient_id: z.string().optional(), status: z.string().optional(), limit: z.number().int().default(50) }, wrap(listPurchaseItems));
|
||||
server.tool("convert_units", "Convert culinary units using ingredient density data.", { ingredient_id: z.string().optional(), quantity: z.number().positive(), from_unit: z.string(), to_unit: z.string() }, wrap(convertUnits));
|
||||
server.tool("list_units", "List measurement units with conversion factors.", { dimension: z.string().optional(), system: z.string().optional() }, wrap(listUnits));
|
||||
server.tool("list_equipment", "List kitchen equipment.", { category: z.string().optional() }, wrap(listEquipment));
|
||||
server.tool("list_prep_actions", "List culinary prep actions.", {}, wrap(listPrepActions));
|
||||
server.tool("list_inventory_counts", "List inventory counting sessions.", { status: z.enum(["all", "open", "completed"]).default("all") }, wrap((args) => { const db = openDb(true); try { let counts = getInventoryCounts(db); if (args.status !== "all") counts = counts.filter((c) => c.status === args.status); return counts; } finally { db.close(); } }));
|
||||
server.tool("get_inventory_count", "Get full inventory count sheet with items and valuations.", { id: z.string() }, wrap((args) => { const db = openDb(true); try { const d = getInventoryCountDetail(db, args.id); if (!d) throw new Error(`Count not found: ${args.id}`); return d; } finally { db.close(); } }));
|
||||
server.tool("list_recipe_books", "List recipe books with recipe counts.", {}, wrap(listRecipeBooks));
|
||||
server.tool("get_recipe_book", "Get recipe book detail with included recipes.", { id: z.string() }, wrap(getRecipeBook));
|
||||
server.tool("export_database_backup", "Export complete JSON backup of all 25 tables.", {}, wrap(() => { const db = openDb(true); try { return exportDatabase(db); } finally { db.close(); } }));
|
||||
server.tool("get_database_stats", "Get entity count statistics.", {}, wrap(getDatabaseStats));
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("Formulation MCP Server running on stdio");
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { exportDatabase } from "../../../../../lib/backup";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) {
|
||||
return Response.json(
|
||||
{ error: "Database is unavailable." },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const bundle = exportDatabase(database);
|
||||
const dateStr = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const filename = `formulation-backup-${dateStr}.json`;
|
||||
|
||||
return new Response(JSON.stringify(bundle, null, 2), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "Export failed." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { importDatabase, type FormulationBackupBundle, type ImportMode } from "../../../../../lib/backup";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) {
|
||||
return Response.json(
|
||||
{ error: "Database is unavailable." },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const mode = (url.searchParams.get("mode") ?? "replace") as ImportMode;
|
||||
const bundle = (await request.json()) as FormulationBackupBundle;
|
||||
|
||||
const result = importDatabase(database, bundle, {
|
||||
mode: mode === "merge" ? "merge" : "replace",
|
||||
rebuildProjections: true,
|
||||
});
|
||||
|
||||
return Response.json(result, { status: 200 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Import failed.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { validateBackupBundle } from "../../../../../lib/backup";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const payload = await request.json();
|
||||
const result = validateBackupBundle(payload);
|
||||
return Response.json(result, { status: result.valid ? 200 : 400 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{
|
||||
valid: false,
|
||||
errors: [
|
||||
error instanceof Error
|
||||
? `Invalid JSON: ${error.message}`
|
||||
: "Invalid JSON payload.",
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { parseIngredientsWithOllama } from "../../../../../lib/ingredient-parser";
|
||||
import { readOnlyMode } from "../../../../../lib/runtime";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
if (readOnlyMode) return Response.json({ error: "Ingredient parsing is unavailable in read-only mode." }, { status: 403 });
|
||||
try {
|
||||
const body = await request.json() as { text?: unknown };
|
||||
if (typeof body.text !== "string") return Response.json({ error: "Ingredient text is required." }, { status: 400 });
|
||||
if (body.text.length > 20_000) return Response.json({ error: "Ingredient text is too long." }, { status: 413 });
|
||||
return Response.json(await parseIngredientsWithOllama(body.text));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.name === "TimeoutError"
|
||||
? "Ingredient parser timed out. Try again."
|
||||
: error instanceof Error ? error.message : "Unable to parse ingredients.";
|
||||
return Response.json({ error: message }, { status: 502 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const archived = tools.listArchivedItems();
|
||||
return Response.json({ success: true, data: archived });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list archived items." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!Array.isArray(body.items) || body.items.length === 0) {
|
||||
return Response.json(
|
||||
{ success: false, error: "Items array with { id, type } elements is required." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const result = tools.restoreArchived({ items: body.items });
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to restore items." },
|
||||
{ status: 400 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing recipe book ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const book = tools.getRecipeBook({ id });
|
||||
return Response.json({ success: true, data: book });
|
||||
} catch (error) {
|
||||
const status = error instanceof Error && error.message.includes("not found") ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to get recipe book." },
|
||||
{ status }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing recipe book ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
body.id = id;
|
||||
const tools = createMcpTools(() => database);
|
||||
const result = tools.saveRecipeBook(body);
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to update recipe book." },
|
||||
{ status: 400 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing recipe book ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const existing = database.prepare("SELECT 1 FROM collections WHERE id = ? AND deleted_at IS NULL").get(id);
|
||||
if (!existing) return Response.json({ success: false, error: "Recipe book not found." }, { status: 404 });
|
||||
|
||||
database.prepare("UPDATE collections SET deleted_at = datetime('now') WHERE id = ?").run(id);
|
||||
return Response.json({ success: true, message: `Recipe book '${id}' archived.` });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to archive recipe book." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const books = tools.listRecipeBooks();
|
||||
return Response.json({ success: true, count: books.length, data: books });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list recipe books." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body.name || !String(body.name).trim()) {
|
||||
return Response.json({ success: false, error: "Recipe book name is required." }, { status: 400 });
|
||||
}
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const result = tools.saveRecipeBook(body);
|
||||
return Response.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to create recipe book." },
|
||||
{ status: 400 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body.quantity || !body.from_unit || !body.to_unit) {
|
||||
return Response.json(
|
||||
{ success: false, error: "Required fields: quantity, from_unit, to_unit" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const result = tools.convertUnits({
|
||||
ingredient_id: body.ingredient_id,
|
||||
quantity: Number(body.quantity),
|
||||
from_unit: body.from_unit,
|
||||
to_unit: body.to_unit,
|
||||
});
|
||||
return Response.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Conversion failed." },
|
||||
{ status: 400 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const tools = createMcpTools(() => database);
|
||||
const equipment = tools.listEquipment({
|
||||
category: url.searchParams.get("category") ?? undefined,
|
||||
});
|
||||
return Response.json({ success: true, count: equipment.length, data: equipment });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list equipment." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing ingredient ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const ingredient = tools.getIngredient({ id });
|
||||
return Response.json({ success: true, data: ingredient });
|
||||
} catch (error) {
|
||||
const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to get ingredient." },
|
||||
{ status }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing ingredient ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
body.id = id;
|
||||
const tools = createMcpTools(() => database);
|
||||
const result = tools.saveIngredient(body);
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to update ingredient." },
|
||||
{ status: 400 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing ingredient ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const result = tools.deleteIngredient({ id });
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
const status = error instanceof Error && error.message.includes("Cannot archive") ? 409 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to delete ingredient." },
|
||||
{ status }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const tools = createMcpTools(() => database);
|
||||
const ingredients = tools.searchIngredients({
|
||||
query: url.searchParams.get("q") ?? url.searchParams.get("query") ?? undefined,
|
||||
category: url.searchParams.get("category") ?? undefined,
|
||||
missing_cost: url.searchParams.get("missing_cost") === "true" ? true : undefined,
|
||||
limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : 50,
|
||||
});
|
||||
return Response.json({ success: true, count: ingredients.length, data: ingredients });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list ingredients." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body.name || !String(body.name).trim()) {
|
||||
return Response.json({ success: false, error: "Ingredient name is required." }, { status: 400 });
|
||||
}
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const result = tools.saveIngredient(body);
|
||||
return Response.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to create ingredient." },
|
||||
{ status: 400 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const statusParam = url.searchParams.get("status");
|
||||
const status = statusParam === "open" || statusParam === "completed" ? statusParam : "all";
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const counts = tools.listInventoryCounts({ status });
|
||||
return Response.json({ success: true, count: counts.length, data: counts });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list inventory counts." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body.title || !body.counted_at) {
|
||||
return Response.json(
|
||||
{ success: false, error: "Required fields: title, counted_at" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const result = tools.createInventoryCountSession({
|
||||
title: body.title,
|
||||
counted_at: body.counted_at,
|
||||
notes: body.notes,
|
||||
prepopulate: body.prepopulate !== false,
|
||||
});
|
||||
return Response.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to create inventory count." },
|
||||
{ status: 400 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing count ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const count = tools.getInventoryCount({ id });
|
||||
return Response.json({ success: true, data: count });
|
||||
} catch (error) {
|
||||
const status = error instanceof Error && error.message.includes("not found") ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to get inventory count." },
|
||||
{ status }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing count ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const tools = createMcpTools(() => database);
|
||||
const result = tools.updateInventoryCount({
|
||||
count_id: id,
|
||||
status: body.status,
|
||||
notes: body.notes,
|
||||
items: body.items,
|
||||
});
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to update inventory count." },
|
||||
{ status: 400 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing count ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const existing = database.prepare("SELECT 1 FROM inventory_counts WHERE id = ? AND deleted_at IS NULL").get(id);
|
||||
if (!existing) return Response.json({ success: false, error: "Inventory count not found." }, { status: 404 });
|
||||
|
||||
database.prepare("UPDATE inventory_counts SET deleted_at = datetime('now') WHERE id = ?").run(id);
|
||||
return Response.json({ success: true, message: `Inventory count '${id}' archived.` });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to archive inventory count." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const actions = tools.listPrepActions();
|
||||
return Response.json({ success: true, count: actions.length, data: actions });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list prep actions." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing purchase item ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const item = tools.getPurchaseItem({ id });
|
||||
return Response.json({ success: true, data: item });
|
||||
} catch (error) {
|
||||
const status = error instanceof Error && error.message.includes("not found") ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to get purchase item." },
|
||||
{ status }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing purchase item ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
body.id = id;
|
||||
const tools = createMcpTools(() => database);
|
||||
const result = tools.savePurchaseItem(body);
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to update purchase item." },
|
||||
{ status: 400 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing purchase item ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const existing = database.prepare("SELECT 1 FROM purchase_items WHERE id = ? AND deleted_at IS NULL").get(id);
|
||||
if (!existing) return Response.json({ success: false, error: "Purchase item not found." }, { status: 404 });
|
||||
|
||||
database.prepare("UPDATE purchase_items SET deleted_at = datetime('now') WHERE id = ?").run(id);
|
||||
return Response.json({ success: true, message: `Purchase item '${id}' archived.` });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to archive purchase item." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ params, request }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing purchase item ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (typeof body.amount !== "number" || body.amount <= 0) {
|
||||
return Response.json(
|
||||
{ success: false, error: "A positive price amount is required." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const result = tools.recordPriceObservation({
|
||||
purchase_item_id: id,
|
||||
amount: body.amount,
|
||||
currency: body.currency,
|
||||
effective_at: body.effective_at,
|
||||
});
|
||||
return Response.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to record price observation." },
|
||||
{ status: 400 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const tools = createMcpTools(() => database);
|
||||
const items = tools.listPurchaseItems({
|
||||
ingredient_id: url.searchParams.get("ingredient_id") ?? undefined,
|
||||
status: url.searchParams.get("status") ?? undefined,
|
||||
limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : 50,
|
||||
});
|
||||
return Response.json({ success: true, count: items.length, data: items });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list purchase items." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body.ingredient_id || !body.name || !body.package_quantity || !body.package_unit_id) {
|
||||
return Response.json(
|
||||
{ success: false, error: "Required fields: ingredient_id, name, package_quantity, package_unit_id" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tools = createMcpTools(() => database);
|
||||
const result = tools.savePurchaseItem(body);
|
||||
return Response.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to create purchase item." },
|
||||
{ status: 400 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ params, request }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const currency = url.searchParams.get("currency") ?? "USD";
|
||||
const tools = createMcpTools(() => database);
|
||||
const cost = tools.calculateRecipeCost({ recipe_id: id, currency });
|
||||
return Response.json({ success: true, data: cost });
|
||||
} catch (error) {
|
||||
const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Cost calculation failed." },
|
||||
{ status }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ params, request }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const tools = createMcpTools(() => database);
|
||||
const recipe = tools.getRecipe({
|
||||
id,
|
||||
scale_factor: url.searchParams.get("scale") ? Number(url.searchParams.get("scale")) : undefined,
|
||||
target_yield: url.searchParams.get("yield") ? Number(url.searchParams.get("yield")) : undefined,
|
||||
target_yield_unit: url.searchParams.get("yield_unit") ?? undefined,
|
||||
});
|
||||
return Response.json({ success: true, data: recipe });
|
||||
} catch (error) {
|
||||
const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to get recipe." },
|
||||
{ status }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
body.id = id;
|
||||
const tools = createMcpTools(() => database);
|
||||
const result = tools.saveRecipe(body);
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to update recipe." },
|
||||
{ status: 400 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const existing = database.prepare("SELECT 1 FROM recipes WHERE id = ? AND deleted_at IS NULL").get(id);
|
||||
if (!existing) return Response.json({ success: false, error: "Recipe not found." }, { status: 404 });
|
||||
|
||||
database.prepare("UPDATE recipes SET deleted_at = datetime('now') WHERE id = ?").run(id);
|
||||
return Response.json({ success: true, message: `Recipe '${id}' archived.` });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to archive recipe." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const nutrition = tools.calculateRecipeNutrition({ recipe_id: id });
|
||||
return Response.json({ success: true, data: nutrition });
|
||||
} catch (error) {
|
||||
const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Nutrition calculation failed." },
|
||||
{ status }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ params, request }) => {
|
||||
const id = params.id;
|
||||
if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 });
|
||||
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const tools = createMcpTools(() => database);
|
||||
const recipe = tools.getRecipe({
|
||||
id,
|
||||
scale_factor: body.scale_factor,
|
||||
target_yield: body.target_yield,
|
||||
target_yield_unit: body.target_yield_unit,
|
||||
});
|
||||
return Response.json({ success: true, data: recipe });
|
||||
} catch (error) {
|
||||
const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500;
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Scaling failed." },
|
||||
{ status }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) {
|
||||
return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const tools = createMcpTools(() => database);
|
||||
const recipes = tools.searchRecipes({
|
||||
query: url.searchParams.get("q") ?? url.searchParams.get("query") ?? undefined,
|
||||
category: url.searchParams.get("category") ?? undefined,
|
||||
tag: url.searchParams.get("tag") ?? undefined,
|
||||
limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : 50,
|
||||
});
|
||||
|
||||
return Response.json({ success: true, count: recipes.length, data: recipes });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list recipes." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) {
|
||||
return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const tools = createMcpTools(() => database);
|
||||
const result = tools.saveRecipe(body);
|
||||
return Response.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to create recipe." },
|
||||
{ status: 400 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => database);
|
||||
const audit = tools.auditRecipeQuality();
|
||||
return Response.json({ success: true, data: audit });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to run quality audit." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { openDatabase } from "../../../../../lib/database";
|
||||
import { createMcpTools } from "../../../../../mcp/tools";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 });
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const tools = createMcpTools(() => database);
|
||||
const units = tools.listUnits({
|
||||
dimension: url.searchParams.get("dimension") ?? undefined,
|
||||
system: url.searchParams.get("system") ?? undefined,
|
||||
});
|
||||
return Response.json({ success: true, count: units.length, data: units });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "Failed to list units." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,452 @@
|
||||
---
|
||||
export const prerender=false;
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import {openDatabase,refreshSiteProjection} from "../../../lib/database";
|
||||
const database=openDatabase({readOnly:false});if(!database)return Astro.redirect("/app/",303);
|
||||
if(Astro.request.method==="POST"){const form=await Astro.request.formData(),type=String(form.get("type")),id=String(form.get("id"));const tables:{[key:string]:string}={recipe:"recipes",ingredient:"ingredients",book:"collections"};if(tables[type])database.prepare(`UPDATE ${tables[type]} SET deleted_at=NULL${type==="ingredient"?",status='active'":""} WHERE id=?`).run(id);refreshSiteProjection(database);database.close();return Astro.redirect("/app/archive/",303);}
|
||||
const items=[...(database.prepare("SELECT id,title name,deleted_at FROM recipes WHERE deleted_at IS NOT NULL").all() as any[]).map(x=>({...x,type:"recipe"})),...(database.prepare("SELECT id,name,deleted_at FROM ingredients WHERE deleted_at IS NOT NULL").all() as any[]).map(x=>({...x,type:"ingredient"})),...(database.prepare("SELECT id,name,deleted_at FROM collections WHERE deleted_at IS NOT NULL").all() as any[]).map(x=>({...x,type:"book"}))].sort((a,b)=>a.name.localeCompare(b.name));database.close();
|
||||
import DetailUtility from "../../../components/DetailUtility.astro";
|
||||
import {
|
||||
openDatabase,
|
||||
permanentlyDeleteArchivedItems,
|
||||
restoreArchivedItems,
|
||||
} from "../../../lib/database";
|
||||
import { titleCase } from "../../../lib/format";
|
||||
import { readOnlyMode } from "../../../lib/runtime";
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Astro.redirect("/app/", 303);
|
||||
|
||||
let error = "";
|
||||
if (Astro.request.method === "POST") {
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
const intent = String(form.get("intent") ?? "restore");
|
||||
|
||||
if (intent === "restore") {
|
||||
const type = String(form.get("type"));
|
||||
const id = String(form.get("id"));
|
||||
restoreArchivedItems(database, [{ id, type }]);
|
||||
database.close();
|
||||
return Astro.redirect("/app/archive/", 303);
|
||||
}
|
||||
|
||||
if (intent === "delete") {
|
||||
const type = String(form.get("type"));
|
||||
const id = String(form.get("id"));
|
||||
permanentlyDeleteArchivedItems(database, [{ id, type }]);
|
||||
database.close();
|
||||
return Astro.redirect("/app/archive/", 303);
|
||||
}
|
||||
|
||||
if (intent === "batch_restore") {
|
||||
const selectedItems = form.getAll("selected_item").map((val) => {
|
||||
const [type, id] = String(val).split(":", 2);
|
||||
return { type, id };
|
||||
});
|
||||
if (selectedItems.length > 0) {
|
||||
restoreArchivedItems(database, selectedItems);
|
||||
}
|
||||
database.close();
|
||||
return Astro.redirect("/app/archive/", 303);
|
||||
}
|
||||
|
||||
if (intent === "batch_delete") {
|
||||
const selectedItems = form.getAll("selected_item").map((val) => {
|
||||
const [type, id] = String(val).split(":", 2);
|
||||
return { type, id };
|
||||
});
|
||||
if (selectedItems.length > 0) {
|
||||
permanentlyDeleteArchivedItems(database, selectedItems);
|
||||
}
|
||||
database.close();
|
||||
return Astro.redirect("/app/archive/", 303);
|
||||
}
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "Action failed.";
|
||||
}
|
||||
}
|
||||
|
||||
const recipes = (
|
||||
database
|
||||
.prepare(
|
||||
"SELECT id, title AS name, deleted_at FROM recipes WHERE deleted_at IS NOT NULL"
|
||||
)
|
||||
.all() as any[]
|
||||
).map((x) => ({ ...x, type: "recipe" as const }));
|
||||
|
||||
const ingredients = (
|
||||
database
|
||||
.prepare(
|
||||
"SELECT id, name, deleted_at FROM ingredients WHERE deleted_at IS NOT NULL"
|
||||
)
|
||||
.all() as any[]
|
||||
).map((x) => ({ ...x, type: "ingredient" as const }));
|
||||
|
||||
const books = (
|
||||
database
|
||||
.prepare(
|
||||
"SELECT id, name, deleted_at FROM collections WHERE deleted_at IS NOT NULL"
|
||||
)
|
||||
.all() as any[]
|
||||
).map((x) => ({ ...x, type: "book" as const }));
|
||||
|
||||
const purchases = (
|
||||
database
|
||||
.prepare(
|
||||
"SELECT id, name, deleted_at FROM purchase_items WHERE deleted_at IS NOT NULL"
|
||||
)
|
||||
.all() as any[]
|
||||
).map((x) => ({ ...x, type: "purchase" as const }));
|
||||
|
||||
const allItems = [...recipes, ...ingredients, ...books, ...purchases].sort(
|
||||
(a, b) => a.name.localeCompare(b.name)
|
||||
);
|
||||
database.close();
|
||||
|
||||
const requestedFilter = Astro.url.searchParams.get("type") ?? "all";
|
||||
const query = (Astro.url.searchParams.get("q") ?? "").trim().toLowerCase();
|
||||
|
||||
const filteredItems = allItems.filter((item) => {
|
||||
if (requestedFilter !== "all" && item.type !== requestedFilter) return false;
|
||||
if (query && !item.name.toLowerCase().includes(query)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
function formatDeleteDate(dateStr: string | null) {
|
||||
if (!dateStr) return "";
|
||||
try {
|
||||
const d = new Date(
|
||||
dateStr.includes("Z") || dateStr.includes("T")
|
||||
? dateStr
|
||||
: `${dateStr.replace(" ", "T")}Z`
|
||||
);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
---
|
||||
<BaseLayout title="Archive"><section class="shell archive-workspace"><header><div><a href="/app/">← All items</a><h1>Archive</h1><p>Restore recipes, ingredients, and recipe books removed from the active workspace.</p></div></header>{items.length?items.map(item=><form method="post" class="archive-row"><input type="hidden" name="type" value={item.type}/><input type="hidden" name="id" value={item.id}/><span class={`workspace-pill-icon ${item.type}`}>{item.type==="recipe"?"▦":item.type==="book"?"▣":"●"}</span><span><strong>{item.name}</strong><small>{item.type} · deleted {item.deleted_at}</small></span><button>Restore</button></form>):<p class="empty-state">Nothing has been archived.</p>}</section></BaseLayout>
|
||||
|
||||
<BaseLayout title="Archive" immersive>
|
||||
<div class="archive-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="archive-workspace">
|
||||
{error && <div class="notice archive-error-banner">{error}</div>}
|
||||
|
||||
<header class="archive-header">
|
||||
<div class="archive-header-left">
|
||||
<nav class="archive-breadcrumbs">
|
||||
<a href="/app/">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>All items</span>
|
||||
</a>
|
||||
</nav>
|
||||
<h1>Archive</h1>
|
||||
<p class="archive-subtitle">
|
||||
Restore recipes, ingredients, recipe books, and purchase items or permanently purge them.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="archive-toolbar">
|
||||
<nav class="archive-filter-chips">
|
||||
<a href="/app/archive/" class:list={["archive-chip", { active: requestedFilter === "all" }]}>
|
||||
<span>All</span>
|
||||
<span class="chip-count">{allItems.length}</span>
|
||||
</a>
|
||||
<a href="/app/archive/?type=recipe" class:list={["archive-chip", { active: requestedFilter === "recipe" }]}>
|
||||
<span>Recipes</span>
|
||||
<span class="chip-count">{recipes.length}</span>
|
||||
</a>
|
||||
<a href="/app/archive/?type=ingredient" class:list={["archive-chip", { active: requestedFilter === "ingredient" }]}>
|
||||
<span>Ingredients</span>
|
||||
<span class="chip-count">{ingredients.length}</span>
|
||||
</a>
|
||||
<a href="/app/archive/?type=book" class:list={["archive-chip", { active: requestedFilter === "book" }]}>
|
||||
<span>Recipe books</span>
|
||||
<span class="chip-count">{books.length}</span>
|
||||
</a>
|
||||
<a href="/app/archive/?type=purchase" class:list={["archive-chip", { active: requestedFilter === "purchase" }]}>
|
||||
<span>Purchase items</span>
|
||||
<span class="chip-count">{purchases.length}</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{filteredItems.length > 0 ? (
|
||||
<form method="post" id="archive-batch-form">
|
||||
<input type="hidden" name="intent" id="batch-intent" value="batch_restore" />
|
||||
|
||||
<div class="archive-table">
|
||||
<div class="archive-table-head">
|
||||
{!readOnlyMode && (
|
||||
<span class="head-col select">
|
||||
<input type="checkbox" id="select-all-archive" aria-label="Select all archived items" />
|
||||
</span>
|
||||
)}
|
||||
<span class="head-col type">Type</span>
|
||||
<span class="head-col name">Item Name</span>
|
||||
<span class="head-col date">Deleted Date</span>
|
||||
<span class="head-col action">Action</span>
|
||||
</div>
|
||||
|
||||
<div class="archive-table-body">
|
||||
{filteredItems.map((item) => (
|
||||
<div class="archive-table-row">
|
||||
{!readOnlyMode && (
|
||||
<div class="archive-select-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="selected_item"
|
||||
value={`${item.type}:${item.id}`}
|
||||
class="archive-item-checkbox"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="archive-type-cell">
|
||||
<span class:list={["workspace-pill-icon", item.type]}>
|
||||
{item.type === "recipe" && (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z" transform="scale(1.1, 1.1) translate(4px, 3.5px)"/>
|
||||
</svg>
|
||||
)}
|
||||
{item.type === "ingredient" && (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z" transform="scale(1.3, 1.3) translate(2.5px, 1px)"/>
|
||||
</svg>
|
||||
)}
|
||||
{item.type === "book" && (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765 2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z" transform="scale(1.1, 1.1) translate(3.5px, 2.5px)"/>
|
||||
</svg>
|
||||
)}
|
||||
{item.type === "purchase" && (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="archive-name-cell">
|
||||
<strong class="archive-item-title">{titleCase(item.name)}</strong>
|
||||
<span class="archive-item-type">{item.type}</span>
|
||||
</div>
|
||||
|
||||
<div class="archive-date-cell">
|
||||
<span class="archive-date-badge">Deleted {formatDeleteDate(item.deleted_at)}</span>
|
||||
</div>
|
||||
|
||||
<div class="archive-action-cell">
|
||||
{!readOnlyMode && (
|
||||
<div class="row-single-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="archive-restore-btn"
|
||||
onclick={`this.form.querySelector('#batch-intent').value='restore'; const hidden = document.createElement('input'); hidden.type='hidden'; hidden.name='type'; hidden.value='${item.type}'; this.form.appendChild(hidden); const hiddenId = document.createElement('input'); hiddenId.type='hidden'; hiddenId.name='id'; hiddenId.value='${item.id}'; this.form.appendChild(hiddenId);`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
|
||||
<path d="M3 3v5h5"></path>
|
||||
</svg>
|
||||
<span>Restore</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="archive-delete-btn"
|
||||
title="Permanently Delete"
|
||||
onclick={`if(!confirm('Permanently delete ${item.name}? This cannot be undone.')) return false; this.form.querySelector('#batch-intent').value='delete'; const hidden = document.createElement('input'); hidden.type='hidden'; hidden.name='type'; hidden.value='${item.type}'; this.form.appendChild(hidden); const hiddenId = document.createElement('input'); hiddenId.type='hidden'; hiddenId.name='id'; hiddenId.value='${item.id}'; this.form.appendChild(hiddenId);`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky Batch Actions Floating Bar -->
|
||||
{!readOnlyMode && (
|
||||
<div id="archive-floating-bar" class="archive-floating-bar hidden">
|
||||
<span id="selected-count-label">0 items selected</span>
|
||||
<div class="floating-bar-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="batch-restore-btn"
|
||||
onclick="document.getElementById('batch-intent').value='batch_restore'"
|
||||
>
|
||||
Restore Selected
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="batch-delete-btn"
|
||||
onclick="if(!confirm('Permanently delete the selected items? Items will be purged from the database.')) return false; document.getElementById('batch-intent').value='batch_delete';"
|
||||
>
|
||||
Permanently Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<div class="archive-empty-state">
|
||||
<div class="empty-icon-circle">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<polyline points="21 8 21 21 3 21 3 8"></polyline>
|
||||
<rect x="1" y="3" width="22" height="5"></rect>
|
||||
<line x1="10" y1="12" x2="14" y2="12"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Nothing in the archive</h3>
|
||||
<p>Archived recipes, ingredients, and recipe books will appear here and can be restored anytime.</p>
|
||||
<a href="/app/" class="archive-back-btn">Return to workspace</a>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script is:inline>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const selectAll = document.getElementById("select-all-archive");
|
||||
const checkboxes = document.querySelectorAll(".archive-item-checkbox");
|
||||
const floatingBar = document.getElementById("archive-floating-bar");
|
||||
const countLabel = document.getElementById("selected-count-label");
|
||||
|
||||
function syncBatchBar() {
|
||||
const checkedCount = document.querySelectorAll(".archive-item-checkbox:checked").length;
|
||||
if (checkedCount > 0) {
|
||||
floatingBar?.classList.remove("hidden");
|
||||
if (countLabel) countLabel.textContent = `${checkedCount} item${checkedCount === 1 ? "" : "s"} selected`;
|
||||
} else {
|
||||
floatingBar?.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener("change", () => {
|
||||
checkboxes.forEach((cb) => {
|
||||
cb.checked = selectAll.checked;
|
||||
});
|
||||
syncBatchBar();
|
||||
});
|
||||
}
|
||||
|
||||
checkboxes.forEach((cb) => {
|
||||
cb.addEventListener("change", syncBatchBar);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.archive-error-banner {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border-left: 4px solid #ef4444;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.archive-table-head,
|
||||
.archive-table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 34px minmax(16rem, 1fr) 10rem 140px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.archive-select-cell,
|
||||
.head-col.select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.row-single-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.archive-delete-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--line);
|
||||
background: white;
|
||||
color: var(--muted);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.archive-delete-btn:hover {
|
||||
border-color: #fca5a5;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Floating Bar */
|
||||
.archive-floating-bar {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #050841;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 10px 25px rgba(5, 8, 65, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
z-index: 1000;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.archive-floating-bar.hidden {
|
||||
display: none;
|
||||
}
|
||||
.floating-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.batch-restore-btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--blue);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.batch-restore-btn:hover {
|
||||
background: var(--blue-hover);
|
||||
}
|
||||
.batch-delete-btn {
|
||||
padding: 6px 14px;
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.batch-delete-btn:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,28 +2,51 @@
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import EntityDirectory from "../../../components/EntityDirectory";
|
||||
const TYPE_ICONS:Record<"recipe"|"ingredient"|"book"|"purchase"|"inventory",string> = {
|
||||
recipe:"M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z",
|
||||
ingredient:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z",
|
||||
book:"M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765 2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z",
|
||||
purchase:"M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z",
|
||||
inventory:"M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 10h2v7H7zm4-3h2v10h-2zm4 6h2v4h-2z"
|
||||
};
|
||||
const TYPE_ICON_TRANSFORMS:Record<"recipe"|"ingredient"|"book"|"purchase"|"inventory",string|undefined> = {
|
||||
recipe:"scale(1.1, 1.1) translate(4px, 3.5px)",
|
||||
ingredient:"scale(1.3, 1.3) translate(2.5px, 1px)",
|
||||
book:"scale(1.1, 1.1) translate(3.5px, 2.5px)",
|
||||
purchase:undefined,
|
||||
inventory:undefined
|
||||
};
|
||||
import { openDatabase } from "../../../lib/database";
|
||||
import { readOnlyMode } from "../../../lib/runtime";
|
||||
import { titleCase } from "../../../lib/format";
|
||||
import type {
|
||||
DirectoryBookRow,
|
||||
DirectoryIngredientRow,
|
||||
DirectoryPurchaseRow,
|
||||
DirectoryRecipeRow,
|
||||
} from "../../../lib/repository";
|
||||
|
||||
const database = openDatabase();
|
||||
if (!database) return new Response("Database unavailable", { status:503 });
|
||||
const recipes=database.prepare(`SELECT r.id,r.title,r.yield_quantity,r.yield_unit_id,
|
||||
if (!database) return new Response("Database unavailable", { status: 503 });
|
||||
const recipes = database.prepare(`SELECT r.id,r.title,r.yield_quantity,r.yield_unit_id,
|
||||
(SELECT count(*) FROM recipe_items ri WHERE ri.recipe_id=r.id) item_count,
|
||||
(SELECT count(*) FROM recipe_steps rs WHERE rs.recipe_id=r.id AND rs.instruction LIKE 'TODO:%') placeholder_count
|
||||
FROM recipes r WHERE r.deleted_at IS NULL ORDER BY r.title`).all() as any[];
|
||||
const ingredients=database.prepare(`SELECT i.id,i.name,i.status,
|
||||
FROM recipes r WHERE r.deleted_at IS NULL ORDER BY r.title`).all() as unknown as DirectoryRecipeRow[];
|
||||
const ingredients = database.prepare(`SELECT i.id,i.name,i.status,
|
||||
(SELECT count(*) FROM recipe_items r WHERE r.ingredient_id=i.id) recipe_count,
|
||||
(SELECT count(*) FROM price_observations po JOIN purchase_items p ON p.id=po.purchase_item_id WHERE p.ingredient_id=i.id) price_count,
|
||||
(SELECT count(*) FROM source_mappings m WHERE m.subject_type='ingredient' AND m.subject_id=i.id AND m.mapping_type='nutrition' AND m.status='reviewed') nutrition_count
|
||||
FROM ingredients i WHERE i.deleted_at IS NULL ORDER BY i.name`).all() as any[];
|
||||
const books=database.prepare("SELECT c.id,c.name,c.description,(SELECT count(*) FROM collection_recipes r WHERE r.collection_id=c.id) recipe_count FROM collections c WHERE c.deleted_at IS NULL ORDER BY c.name").all() as any[];
|
||||
const purchases=database.prepare(`SELECT p.id,p.ingredient_id,p.name,p.supplier_id,p.status,i.name ingredient_name,p.package_quantity,p.package_unit_id,
|
||||
FROM ingredients i WHERE i.deleted_at IS NULL ORDER BY i.name`).all() as unknown as DirectoryIngredientRow[];
|
||||
const books = database.prepare("SELECT c.id,c.name,c.description,(SELECT count(*) FROM collection_recipes r WHERE r.collection_id=c.id) recipe_count FROM collections c WHERE c.deleted_at IS NULL ORDER BY c.name").all() as unknown as DirectoryBookRow[];
|
||||
const purchases = database.prepare(`SELECT p.id,p.ingredient_id,p.name,p.supplier_id,p.status,i.name ingredient_name,p.package_quantity,p.package_unit_id,
|
||||
(SELECT amount FROM price_observations x WHERE x.purchase_item_id=p.id ORDER BY effective_at DESC LIMIT 1) latest_price
|
||||
FROM purchase_items p JOIN ingredients i ON i.id=p.ingredient_id ORDER BY p.name`).all() as any[];
|
||||
FROM purchase_items p JOIN ingredients i ON i.id=p.ingredient_id ORDER BY p.name`).all() as unknown as DirectoryPurchaseRow[];
|
||||
const inventoryCounts = database.prepare("SELECT count(*) as c FROM inventory_counts WHERE deleted_at IS NULL").get() as { c: number } | undefined;
|
||||
database.close();
|
||||
|
||||
const requested=Astro.url.searchParams.get("type")??"ingredient";
|
||||
const type=["recipe","ingredient","book","purchase"].includes(requested)?requested:"ingredient";
|
||||
const requested=Astro.url.searchParams.get("type");
|
||||
if (requested === "inventory") return Astro.redirect("/app/inventory/", 303);
|
||||
const type=["recipe","ingredient","book","purchase"].includes(requested??"")?requested:undefined;
|
||||
const query=(Astro.url.searchParams.get("q")??"").trim();
|
||||
const normalizedQuery=query.toLocaleLowerCase();
|
||||
const validSearchTypes=["recipe","ingredient","book","purchase"];
|
||||
@@ -33,61 +56,70 @@ const attention=Astro.url.searchParams.get("attention")==="1", missingCost=Astro
|
||||
const filtering=attention||missingCost||noUsda||unused||emptyRecipe||placeholderSteps;
|
||||
const filteredIngredients=ingredients.filter((ingredient)=>{if(!filtering)return true;const selected=[missingCost&&ingredient.price_count===0,noUsda&&ingredient.nutrition_count===0,unused&&ingredient.recipe_count===0].filter(Boolean);return missingCost||noUsda||unused?selected.length>0:ingredient.price_count===0||ingredient.nutrition_count===0||ingredient.recipe_count===0;});
|
||||
const filteredRecipes=recipes.filter(recipe=>!filtering||(emptyRecipe&&recipe.item_count===0)||(placeholderSteps&&recipe.placeholder_count>0)||(!emptyRecipe&&!placeholderSteps&&(recipe.item_count===0||recipe.placeholder_count>0)));
|
||||
const tabs=[
|
||||
{type:"recipe",label:"Recipes",count:recipes.length,icon:"▦",kind:"recipe"},
|
||||
{type:"ingredient",label:"Ingredients",count:ingredients.length,icon:"●",kind:"ingredient"},
|
||||
{type:"book",label:"Recipe books",count:books.length,icon:"▣",kind:"book"},
|
||||
{type:"purchase",label:"Purchase items",count:purchases.length,icon:"$",kind:"purchase"},
|
||||
const FILTER_ICON_PATH="M18 6.0201C18 4.81608 17.1873 3.79266 16.0736 3.46156L16.0736 0.722412C16.0736 0.301005 15.7425 -9.86801e-08 15.3211 -1.171e-07C14.8996 -1.35521e-07 14.5685 0.331105 14.5685 0.752512L14.5685 3.49166C13.4548 3.79266 12.6421 4.84618 12.6421 6.0502C12.6421 7.28432 13.4548 8.30774 14.5685 8.63884L14.5685 19.7459C14.5685 20.1673 14.8996 20.4984 15.3211 20.4984C15.7425 20.4984 16.0736 20.1673 16.0736 19.7459L16.0736 8.63884C17.1873 8.27764 18 7.25422 18 6.0201ZM16.495 6.0502C16.495 6.68231 15.9833 7.22412 15.3211 7.22412C14.6588 7.22412 14.1471 6.68231 14.1471 6.0502C14.1471 5.41809 14.6588 4.87628 15.3211 4.87628C15.9833 4.87628 16.495 5.41809 16.495 6.0502Z";
|
||||
const tabs:Array<{type:string;label:string;count:number;kind:"recipe"|"ingredient"|"book"|"purchase"|"inventory";href?:string}>=[
|
||||
{type:"recipe",label:"Recipes",count:recipes.length,kind:"recipe"},
|
||||
{type:"ingredient",label:"Ingredients",count:ingredients.length,kind:"ingredient"},
|
||||
{type:"book",label:"Recipe books",count:books.length,kind:"book"},
|
||||
{type:"purchase",label:"Purchase items",count:purchases.length,kind:"purchase"},
|
||||
{type:"inventory",label:"Inventory",count:inventoryCounts?.c ?? 0,kind:"inventory",href:"/app/inventory/"},
|
||||
];
|
||||
const allSearchResults=normalizedQuery ? [
|
||||
...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"recipe",label:"Recipe",icon:"▦",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})),
|
||||
...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"ingredient",label:"Ingredient",icon:"●",name:item.name,detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})),
|
||||
...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"book",label:"Recipe book",icon:"▣",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:"/app/?type=book"})),
|
||||
...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"purchase",label:"Purchase item",icon:"$",name:item.name,detail:item.ingredient_name,href:`/app/ingredients/${item.ingredient_id}/#costs`})),
|
||||
...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"recipe" as const,label:"Recipe",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})),
|
||||
...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"ingredient" as const,label:"Ingredient",name:titleCase(item.name),detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})),
|
||||
...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"book" as const,label:"Recipe book",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/recipe-books/${item.id}/`})),
|
||||
...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"purchase" as const,label:"Purchase item",name:item.name,detail:titleCase(item.ingredient_name),href:`/app/ingredients/${item.ingredient_id}/#costs`})),
|
||||
].sort((a,b)=>a.name.localeCompare(b.name)):[];
|
||||
const searchResults=filteringSearchTypes?allSearchResults.filter((result)=>selectedSearchTypes.includes(result.kind)):allSearchResults;
|
||||
const ingredientRows=filteredIngredients.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.id}/`,kind:"ingredient" as const,icon:"●"}));
|
||||
const recipeRows=filteredRecipes.map(item=>({id:item.id,name:item.title,href:`/app/recipes/${item.id}/`,kind:"recipe" as const,icon:"▦"}));
|
||||
const bookRows=books.map(item=>({id:item.id,name:item.name,href:`/app/recipe-books/${item.id}/`,kind:"book" as const,icon:"▣"}));
|
||||
const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.ingredient_id}/#costs`,kind:"purchase" as const,icon:"$"}));
|
||||
const searchRows=searchResults.map(({id,kind,name,href,label,detail})=>({id,name,href,kind,detail:`${label} · ${detail}`}));
|
||||
const ingredientRows=filteredIngredients.map(item=>({id:item.id,name:titleCase(item.name),href:`/app/ingredients/${item.id}/`,kind:"ingredient" as const}));
|
||||
const recipeRows=filteredRecipes.map(item=>({id:item.id,name:item.title,href:`/app/recipes/${item.id}/`,kind:"recipe" as const}));
|
||||
const bookRows=books.map(item=>({id:item.id,name:item.name,href:`/app/recipe-books/${item.id}/`,kind:"book" as const}));
|
||||
const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.ingredient_id}/#costs`,kind:"purchase" as const}));
|
||||
---
|
||||
<BaseLayout title="Recipe management"><section class="shell directory-workspace">
|
||||
<div class="workspace-search-tools">
|
||||
<BaseLayout title="Recipe management"><div class="workspace-search-tools"><div class="workspace-search-tools-inner">
|
||||
<form class="workspace-global-search" method="get" action="/app/" role="search">
|
||||
<input type="hidden" name="type" value={type}/>
|
||||
<input type="hidden" name="type" value={type??""}/>
|
||||
{selectedSearchTypes.map((selectedType)=><input type="hidden" name="item_type" value={selectedType}/>)}
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input type="search" name="q" value={query} placeholder="Search recipes, ingredients, recipe books, and purchase items" aria-label="Search all items" autofocus={Boolean(query)}/>
|
||||
{query&&<a href={`/app/?type=${type}`} aria-label="Clear search">×</a>}
|
||||
<svg class="search-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
<input type="search" name="q" value={query} placeholder="Search " aria-label="Search all items" autofocus={Boolean(query)}/>
|
||||
{query&&<a href={type?`/app/?type=${type}`:"/app/"} aria-label="Clear search">×</a>}
|
||||
</form>
|
||||
<details class="search-type-filter" open={filteringSearchTypes}>
|
||||
<summary>☷ Item type{filteringSearchTypes?` · ${selectedSearchTypes.length}`:""}</summary>
|
||||
<form method="get" action="/app/"><input type="hidden" name="type" value={type}/><input type="hidden" name="q" value={query}/>
|
||||
<summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Item type{filteringSearchTypes?` · ${selectedSearchTypes.length}`:""}</summary>
|
||||
<form method="get" action="/app/"><input type="hidden" name="type" value={type??""}/><input type="hidden" name="q" value={query}/>
|
||||
<label><input type="checkbox" name="item_type" value="recipe" checked={selectedSearchTypes.includes("recipe")}/> Recipes</label>
|
||||
<label><input type="checkbox" name="item_type" value="ingredient" checked={selectedSearchTypes.includes("ingredient")}/> Ingredients</label>
|
||||
<label><input type="checkbox" name="item_type" value="book" checked={selectedSearchTypes.includes("book")}/> Recipe books</label>
|
||||
<label><input type="checkbox" name="item_type" value="purchase" checked={selectedSearchTypes.includes("purchase")}/> Purchase items</label>
|
||||
<div><button>Apply</button>{filteringSearchTypes&&<a href={`/app/?type=${type}&q=${encodeURIComponent(query)}`}>All types</a>}</div>
|
||||
<div><button>Apply</button>{filteringSearchTypes&&<a href={type?`/app/?type=${type}&q=${encodeURIComponent(query)}`:`/app/?q=${encodeURIComponent(query)}`}>All types</a>}</div>
|
||||
</form>
|
||||
</details>
|
||||
{!readOnlyMode&&<details class="workspace-new-menu">
|
||||
<summary><span class="new-trigger-plus" aria-hidden="true">+</span><span>New</span></summary>
|
||||
<summary><span class="new-trigger-plus" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg></span><span class="new-trigger-label">New</span></summary>
|
||||
<nav aria-label="Create new item">
|
||||
<a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true">▦</span><strong>Recipe</strong></a>
|
||||
<a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true">▣</span><strong>Recipe book</strong></a>
|
||||
<a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill="currentColor" d={TYPE_ICONS.recipe} style={TYPE_ICON_TRANSFORMS.recipe?{transform:TYPE_ICON_TRANSFORMS.recipe}:undefined}/></svg></span><strong>Recipe</strong></a>
|
||||
<a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill="currentColor" d={TYPE_ICONS.book} style={TYPE_ICON_TRANSFORMS.book?{transform:TYPE_ICON_TRANSFORMS.book}:undefined}/></svg></span><strong>Recipe book</strong></a>
|
||||
</nav>
|
||||
</details>}
|
||||
</div>
|
||||
</div>
|
||||
<nav class="workspace-pills" aria-label="Workspaces">
|
||||
{tabs.map((tab)=><a class:list={{active:type===tab.type}} href={`/app/?type=${tab.type}`}><span class={`workspace-pill-icon ${tab.kind}`}>{tab.icon}</span>{tab.label} <small>{tab.count}</small></a>)}
|
||||
{type==="ingredient"&&<details class="filter-menu" open={filtering}><summary>☷ Filter{filtering?` · ${filteredIngredients.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="ingredient"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="missing_cost" value="1" checked={missingCost}/> Missing cost</label><label><input type="checkbox" name="no_usda" value="1" checked={noUsda}/> No USDA map</label><label><input type="checkbox" name="unused" value="1" checked={unused}/> Unused ingredient</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=ingredient">Clear</a>}</div></form></details>}
|
||||
{type==="recipe"&&<details class="filter-menu" open={filtering}><summary>☷ Filter{filtering?` · ${filteredRecipes.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="recipe"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="empty_recipe" value="1" checked={emptyRecipe}/> Empty recipe</label><label><input type="checkbox" name="placeholder_steps" value="1" checked={placeholderSteps}/> Placeholder instructions</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=recipe">Clear</a>}</div></form></details>}
|
||||
{!readOnlyMode&&<a class="archive-link" href="/app/archive/">Archive</a>}
|
||||
<section class="shell directory-workspace">
|
||||
<nav class="workspace-pills" aria-label="Workspaces">
|
||||
{tabs.map((tab)=><a class:list={{active:type===tab.type}} href={tab.href ?? `/app/?type=${tab.type}`}><span class={`workspace-pill-icon ${tab.kind}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d={TYPE_ICONS[tab.kind]} style={TYPE_ICON_TRANSFORMS[tab.kind]?{transform:TYPE_ICON_TRANSFORMS[tab.kind]}:undefined}/></svg></span><span>{tab.label}</span><small>{tab.count}</small></a>)}
|
||||
{type==="ingredient"&&<details class="filter-menu" open={filtering}><summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Filter{filtering?` · ${filteredIngredients.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="ingredient"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="missing_cost" value="1" checked={missingCost}/> Missing cost</label><label><input type="checkbox" name="no_usda" value="1" checked={noUsda}/> No USDA map</label><label><input type="checkbox" name="unused" value="1" checked={unused}/> Unused ingredient</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=ingredient">Clear</a>}</div></form></details>}
|
||||
{type==="recipe"&&<details class="filter-menu" open={filtering}><summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Filter{filtering?` · ${filteredRecipes.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="recipe"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="empty_recipe" value="1" checked={emptyRecipe}/> Empty recipe</label><label><input type="checkbox" name="placeholder_steps" value="1" checked={placeholderSteps}/> Placeholder instructions</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=recipe">Clear</a>}</div></form></details>}
|
||||
{!readOnlyMode&&<>
|
||||
<a class="archive-link" href="/app/archive/">Archive</a>
|
||||
<a class="archive-link" href="/app/settings/">Data Management</a>
|
||||
</>}
|
||||
</nav>
|
||||
{query?<section class="workspace-search-results" aria-live="polite"><p><strong>{searchResults.length}</strong> {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}</p>{searchResults.length?<div>{searchResults.map((result)=><a href={result.href}><span class={`workspace-pill-icon ${result.kind}`}>{result.icon}</span><span><strong>{result.name}</strong><small>{result.detail}</small></span><em>{result.label}</em><b>›</b></a>)}</div>:<div class="empty-state">No items of the selected types match this search.</div>}</section>:<>
|
||||
{query?<section class="workspace-search-results" aria-live="polite"><p><strong>{searchResults.length}</strong> {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}</p>{searchRows.length?<EntityDirectory client:load rows={searchRows} emptyMessage="No items of the selected types match this search." readOnly={readOnlyMode}/>:<div class="empty-state">No items of the selected types match this search.</div>}</section>:<>
|
||||
{type==="ingredient"&&<EntityDirectory client:load rows={ingredientRows} entityType="ingredient" emptyMessage="No ingredients match these filters." readOnly={readOnlyMode}/>}
|
||||
{type==="recipe"&&<EntityDirectory client:load rows={recipeRows} entityType="recipe" emptyMessage="No recipes yet." readOnly={readOnlyMode}/>}
|
||||
{type==="book"&&<EntityDirectory client:load rows={bookRows} entityType="book" emptyMessage="No recipe books yet." readOnly={readOnlyMode}/>}
|
||||
{type==="purchase"&&<EntityDirectory client:load rows={purchaseRows} entityType="purchase" emptyMessage="No purchase items yet." readOnly={readOnlyMode}/>}
|
||||
{!type&&<p class="empty-state">Select a workspace or search to browse recipes, ingredients, and purchase items.</p>}
|
||||
</>}
|
||||
</section></BaseLayout>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,7 +4,7 @@ import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import { openDatabase, refreshSiteProjection } from "../../../../lib/database";
|
||||
import { readOnlyMode } from "../../../../lib/runtime";
|
||||
import { bestUsdaPortions, fetchUsdaFood, usdaNutrition } from "../../../../lib/usda";
|
||||
import { number } from "../../../../lib/format";
|
||||
import { number, titleCase } from "../../../../lib/format";
|
||||
import PurchaseItemForm from "../../../../components/PurchaseItemForm.astro";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
const id = Astro.params.id!;
|
||||
@@ -126,29 +126,333 @@ const prepDisplay = prep.map((row) => {
|
||||
return measure ? `${number(measure.quantity)} ${unitById.get(measure.unit_id)?.symbol ?? measure.unit_id}` : "—";
|
||||
};
|
||||
return {...row,weight:showMeasure("mass"),volume:showMeasure("volume"),each:showMeasure("count")};
|
||||
});
|
||||
});const RECIPE_TAB_ICONS = {
|
||||
overview: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z",
|
||||
costing: "M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4",
|
||||
equivalencies: "M19.4 3.3h-6.6v-.5c0-.4-.3-.7-.8-.7-.4 0-.8.3-.8.7v.5H4.6L0 14s.2 3.8 4.7 3.8S9.4 14 9.4 14L6.1 6.2h5.1v15.7h1.5V6.2h5.1L14.6 14s.2 3.8 4.7 3.8S24 14 24 14L19.4 3.3zM7.7 14H1.5l3.1-7.4L7.7 14zm8.5 0l3.1-7.4 3.1 7.4h-6.2z",
|
||||
nutrition: "M9.42859 2.37431L9.80926 2.82134L10.1899 2.37431C11.1674 1.22652 12.668 0.5 14.2234 0.5C16.9685 0.5 19.1185 2.64998 19.1185 5.3951C19.1185 7.0848 18.3631 8.65707 16.9325 10.4062C15.4961 12.1623 13.4317 14.0352 10.8957 16.3348L10.895 16.3354L9.80799 17.325L8.72319 16.345L8.72211 16.344L8.71119 16.3341C6.18062 14.0344 4.12043 12.1623 2.68618 10.4075C1.25541 8.6571 0.5 7.08481 0.5 5.3951C0.5 2.64998 2.64998 0.5 5.3951 0.5C6.95051 0.5 8.45117 1.22652 9.42859 2.37431ZM3.70568 10.127C5.0829 11.7363 7.04455 13.5134 9.36637 15.6157L9.45571 15.7051L9.80926 16.0586L10.1628 15.7051L10.2522 15.6157C12.574 13.5134 14.5356 11.7363 15.9128 10.127C17.287 8.52131 18.1567 6.99709 18.1567 5.3951C18.1567 3.1571 16.4614 1.46185 14.2234 1.46185C12.6415 1.46185 11.0895 2.39876 10.4049 3.77684H9.22149C8.52967 2.40009 6.97866 1.46185 5.3951 1.46185C3.1571 1.46185 1.46185 3.1571 1.46185 5.3951C1.46185 6.99709 2.3315 8.52131 3.70568 10.127Z"
|
||||
};
|
||||
const RECIPE_TAB_VIEWBOX = { overview: "0 0 24 24", costing: "0 0 24 24", equivalencies: "0 0 24 24", nutrition: "0 0 20 18" };
|
||||
const recipeTabIcon = (name: string) => `<span class="recipe-tab-icon"><svg viewBox="${RECIPE_TAB_VIEWBOX[name as keyof typeof RECIPE_TAB_VIEWBOX]}" aria-hidden="true"><path fill="currentColor" d="${RECIPE_TAB_ICONS[name as keyof typeof RECIPE_TAB_ICONS]}"/></svg></span>`;
|
||||
database.close();
|
||||
---
|
||||
<BaseLayout title={ingredient.name} immersive>
|
||||
<section class="entity-detail-shell">
|
||||
<DetailUtility section="Ingredients" sectionHref="/app/?type=ingredient" />
|
||||
<header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=ingredient">← Ingredients</a></p>{editing?<textarea class="editable-entity-title ingredient-title-editor" name="name" form="ingredient-identity-form" aria-label="Ingredient name" rows="1" required>{ingredient.name}</textarea>:<h1>{ingredient.name}</h1>}</div>{!readOnlyMode&&<div class="entity-header-actions">{editing?<button class="primary-command" id="ingredient-done" type="button">✓ Done</button>:<a class="edit-command" href={`/app/ingredients/${id}/?edit=1`}>✎ Edit</a>}{editing&&<details class="detail-actions-menu ingredient-actions-menu"><summary aria-label="Ingredient actions">⋮</summary><div><form method="post" data-confirm-message="Merge this ingredient? This changes every recipe that uses it."><label><span>Merge into</span><select name="target_id" required><option value="">Select canonical ingredient</option>{mergeCandidates.map(candidate=><option value={candidate.id}>{candidate.name}</option>)}</select></label><button name="intent" value="merge">Merge ingredient</button></form></div></details>}</div>}</header>
|
||||
{message&&<div class="success-notice entity-notice">{message}</div>}{error&&<div class="notice entity-notice">{error}</div>}
|
||||
<div class="entity-detail-grid">
|
||||
<main class="entity-primary">
|
||||
{editing&&<form method="post" id="ingredient-identity-form" class="ingredient-identity-form ingredient-identity-data"><input type="hidden" name="intent" value="identity"/><input type="hidden" name="status" value={ingredient.status}/><input type="hidden" id="ingredient-prep-json" name="prep_json" value="[]"/></form>}
|
||||
<section id="prep" class="entity-section ingredient-prep-section"><h2>Prep Actions</h2><p class="panel-intro">Any action taken on an ingredient that changes its yield or its weight-to-volume equivalency from the original raw state.</p>{editing?<><div class="prep-edit-wrap"><table class="prep-action-table prep-edit-table"><thead><tr><th>Prep Action</th><th>Yield %</th><th>Weight</th><th>Volume</th><th>Each</th><th></th></tr></thead><tbody id="ingredient-prep-rows" data-actions={JSON.stringify(actions)}>{prepDisplay.map(x=><tr class="prep-edit-row"><td><select class="prep-row-action" aria-label="Prep action">{actions.map(action=><option value={action.id} selected={action.id===x.action_id}>{action.name}</option>)}</select><input class="prep-row-notes" value={x.notes??""} placeholder="Optional notes" aria-label="Prep action notes"/></td><td><span class="percent-input"><input class="prep-row-yield" type="number" min="0.01" step="0.01" required value={x.yield_factor*100} aria-label="Yield percent"/><i>%</i></span></td>{[x.weight,x.volume,x.each].map(value=><td><button class="prep-equivalency-link" type="button" title="Edit this prep action's UoM equivalency">{value==="—"?"Set":value}</button></td>)}<td><button class="prep-row-remove" type="button" aria-label={`Remove ${x.name}`}>×</button></td></tr>)}</tbody></table></div><button id="add-prep-row" class="outlined-add-action" type="button">+ Add Prep Action</button><p class="field-help">Use 100% for no change, 80% for trim or cooking loss, or 250% when cooking produces 2.5 times the original weight. Select Weight, Volume, or Each to define its equivalency.</p></>:prep.length?<table class="prep-action-table"><thead><tr><th>Prep Action</th><th>Yield %</th><th>Weight</th><th>Volume</th><th>Each</th></tr></thead><tbody>{prepDisplay.map(x=><tr><td><strong>{x.name}</strong>{x.notes&&<small>{x.notes}</small>}</td><td>{number(x.yield_factor*100)}%</td><td>{x.weight}</td><td>{x.volume}</td><td>{x.each}</td></tr>)}</tbody></table>:<p class="empty-copy">No prep actions defined.</p>}</section>
|
||||
<section id="usage" class:list={["entity-section","additional-card",{"ingredient-additional-edit":editing}]}><h2>Additional Details</h2>{editing?<div class="ingredient-detail-fields"><label><span>Tags</span><input id="ingredient-tags" name="tags" form="ingredient-identity-form" value={ingredientTags.join(", ")} placeholder="Tag Name"/></label><label><span>Description</span><textarea name="description" form="ingredient-identity-form" rows="4" placeholder="Write your ingredient description">{ingredient.description??""}</textarea></label><label><span>Ingredient aliases</span><small>One alias per line</small><textarea name="aliases" form="ingredient-identity-form" rows="4">{aliases.map(x=>x.name).join("\n")}</textarea></label></div>:<><details open><summary>Recipes On <small>{usedIn.length}</small></summary>{usedIn.length?<ul>{usedIn.map(x=><li><a href={`/app/recipes/${x.id}/`}>{x.title}</a></li>)}</ul>:<p>This ingredient is not used by a recipe.</p>}</details>{ingredientTags.length>0&&<div class="ingredient-tag-view"><strong>Tags</strong><span>{ingredientTags.map(tag=><i>{tag}</i>)}</span></div>}{ingredient.description&&<div class="ingredient-description-view"><strong>Description</strong><p>{ingredient.description}</p></div>}<details><summary>Ingredient aliases <small>{aliases.length}</small></summary>{aliases.length?<ul>{aliases.map(x=><li>{x.name}</li>)}</ul>:<p>No aliases.</p>}</details></>}</section>
|
||||
<BaseLayout title={editing ? `Edit ${titleCase(ingredient.name)}` : titleCase(ingredient.name)} immersive>
|
||||
<section class="recipe-detail-shell recipe-read-shell ingredient-read-shell">
|
||||
<DetailUtility section="Ingredients" sectionHref="/app/?type=ingredient">
|
||||
<div class="entity-header-actions">
|
||||
{editing ? (
|
||||
<button class="primary-command" id="ingredient-done" type="button">✓ Done</button>
|
||||
) : !readOnlyMode && (
|
||||
<a class="edit-command" href={`/app/ingredients/${id}/?edit=1`}><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>Edit</a>
|
||||
)}
|
||||
{editing && (
|
||||
<details class="detail-actions-menu ingredient-actions-menu">
|
||||
<summary aria-label="Ingredient actions">⋮</summary>
|
||||
<div>
|
||||
<form method="post" data-confirm-message="Merge this ingredient? This changes every recipe that uses it.">
|
||||
<label><span>Merge into</span><select name="target_id" required><option value="">Select canonical ingredient</option>{mergeCandidates.map(candidate=><option value={candidate.id}>{candidate.name}</option>)}</select></label>
|
||||
<button name="intent" value="merge">Merge ingredient</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</DetailUtility>
|
||||
|
||||
<div class="recipe-read-left ingredient-read-left">
|
||||
<header class="entity-detail-header">
|
||||
<div>
|
||||
<p class="entity-breadcrumb"><a href="/app/?type=ingredient">← Ingredients</a></p>
|
||||
{editing ? (
|
||||
<textarea class="editable-entity-title ingredient-title-editor" name="name" form="ingredient-identity-form" aria-label="Ingredient name" rows="1" required>{ingredient.name}</textarea>
|
||||
) : (
|
||||
<h1>{titleCase(ingredient.name)}</h1>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{message && <div class="success-notice entity-notice">{message}</div>}
|
||||
{error && <div class="notice entity-notice">{error}</div>}
|
||||
|
||||
<main class="ingredient-main-content entity-tab-panel active" data-panel="overview">
|
||||
{editing && (
|
||||
<form method="post" id="ingredient-identity-form" class="ingredient-identity-form ingredient-identity-data">
|
||||
<input type="hidden" name="intent" value="identity"/>
|
||||
<input type="hidden" name="status" value={ingredient.status}/>
|
||||
<input type="hidden" id="ingredient-prep-json" name="prep_json" value="[]"/>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<section id="prep" class="entity-section ingredient-prep-section">
|
||||
<h2>Prep Actions</h2>
|
||||
<p class="panel-intro">Any action taken on an ingredient that changes its yield or its weight-to-volume equivalency from the original raw state.</p>
|
||||
{editing ? (
|
||||
<>
|
||||
<button id="add-prep-row" class="outlined-add-action" type="button">+ Add Prep Action</button>
|
||||
<div class="prep-edit-wrap">
|
||||
<table class="prep-action-table prep-edit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:35%;">Prep Action</th>
|
||||
<th style="width:18%;">Yield %</th>
|
||||
<th style="width:15%;">Weight</th>
|
||||
<th style="width:15%;">Volume</th>
|
||||
<th style="width:12%;">Each</th>
|
||||
<th style="width:5%;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ingredient-prep-rows" data-actions={JSON.stringify(actions)}>
|
||||
{prepDisplay.map(x => (
|
||||
<tr class="prep-edit-row">
|
||||
<td>
|
||||
<select class="prep-row-action" aria-label="Prep action">
|
||||
{actions.map(action => (
|
||||
<option value={action.id} selected={action.id === x.action_id}>{action.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<input class="prep-row-notes" value={x.notes ?? ""} placeholder="Optional notes" aria-label="Prep action notes"/>
|
||||
</td>
|
||||
<td>
|
||||
<span class="percent-input">
|
||||
<input class="prep-row-yield" type="number" min="0.01" step="0.01" required value={x.yield_factor * 100} aria-label="Yield percent"/>
|
||||
<i>%</i>
|
||||
</span>
|
||||
</td>
|
||||
{[x.weight, x.volume, x.each].map(value => (
|
||||
<td>
|
||||
<button class="prep-equivalency-link" type="button" title="Edit this prep action's UoM equivalency">{value === "—" ? "Set" : value}</button>
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
<button class="prep-row-remove" type="button" aria-label={`Remove ${x.name}`}>×</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="field-help">Use 100% for no change, 80% for trim or cooking loss, or 250% when cooking produces 2.5 times the original weight. Select Weight, Volume, or Each to define its equivalency.</p>
|
||||
</>
|
||||
) : prep.length ? (
|
||||
<table class="prep-action-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%;">Prep Action</th>
|
||||
<th style="width:20%;">Yield %</th>
|
||||
<th style="width:15%;">Weight</th>
|
||||
<th style="width:15%;">Volume</th>
|
||||
<th style="width:10%;">Each</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{prepDisplay.map(x => (
|
||||
<tr>
|
||||
<td><strong>{x.name}</strong>{x.notes && <small>{x.notes}</small>}</td>
|
||||
<td>{number(x.yield_factor * 100)}%</td>
|
||||
<td>{x.weight}</td>
|
||||
<td>{x.volume}</td>
|
||||
<td>{x.each}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p class="empty-copy">This ingredient currently has no prep actions. Edit ingredient to add prep actions.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section id="usage" class:list={["entity-section", "additional-card", { "ingredient-additional-edit": editing }]}>
|
||||
<h2>Additional Details</h2>
|
||||
{editing ? (
|
||||
<div class="ingredient-detail-fields">
|
||||
<label>
|
||||
<span>Tags</span>
|
||||
<input id="ingredient-tags" name="tags" form="ingredient-identity-form" value={ingredientTags.join(", ")} placeholder="Tag Name"/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<textarea name="description" form="ingredient-identity-form" rows="4" placeholder="Write your ingredient description">{ingredient.description ?? ""}</textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Ingredient aliases</span>
|
||||
<small>One alias per line</small>
|
||||
<textarea name="aliases" form="ingredient-identity-form" rows="4">{aliases.map(x => x.name).join("\n")}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<details open>
|
||||
<summary>Recipes On <small>{usedIn.length}</small></summary>
|
||||
{usedIn.length ? <ul>{usedIn.map(x => <li><a href={`/app/recipes/${x.id}/`}>{x.title}</a></li>)}</ul> : <p>This ingredient is not used by a recipe.</p>}
|
||||
</details>
|
||||
{ingredientTags.length > 0 && (
|
||||
<div class="ingredient-tag-view">
|
||||
<strong>Tags</strong>
|
||||
<span>{ingredientTags.map(tag => <i>{tag}</i>)}</span>
|
||||
</div>
|
||||
)}
|
||||
{ingredient.description && (
|
||||
<div class="ingredient-description-view">
|
||||
<strong>Description</strong>
|
||||
<p>{ingredient.description}</p>
|
||||
</div>
|
||||
)}
|
||||
<details>
|
||||
<summary>Ingredient aliases <small>{aliases.length}</small></summary>
|
||||
{aliases.length ? <ul>{aliases.map(x => <li>{x.name}</li>)}</ul> : <p>No aliases.</p>}
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
<aside class="entity-secondary">
|
||||
<nav class="entity-tabs" aria-label="Ingredient details"><button class="active" type="button" data-tab="costs">$ <span>Cost</span></button><button type="button" data-tab="equivalencies">⚖ <span>UoM Equivalency</span></button><button type="button" data-tab="nutrition">♡ <span>Nutrition</span></button></nav>
|
||||
<section id="costs" class="workspace-panel entity-tab-panel active" data-panel="costs"><h2>Ingredient Cost</h2><p class="panel-intro">Purchase packages, usable yield, and current prices.</p>{editing?<div class="ingredient-cost-edit">{purchases.map(x=><section class="ingredient-purchase-form"><header><h3>{x.name}</h3><form method="post" data-confirm-message="Remove this purchase item and its price history?"><input type="hidden" name="purchase_item_id" value={x.id}/><button name="intent" value="delete_purchase_item">Remove</button></form></header><PurchaseItemForm item={x} units={units}/></section>)}<details class="purchase-editor add-purchase" open={purchases.length===0}><summary>+ Add purchase item</summary><PurchaseItemForm units={units}/></details></div>:purchases.length?purchases.map(x=><div class="ingredient-cost-row"><span><strong>{x.name}</strong><small>{x.supplier_id||"No supplier"} · {x.status}</small></span><span><strong>{x.latest_price!=null?`${x.latest_currency} ${Number(x.latest_price).toFixed(2)}`:"No price"}</strong><small>{number(x.package_quantity)} {unitById.get(x.package_unit_id)?.symbol??x.package_unit_id}{x.units_per_case>1?` × ${x.units_per_case}`:""}</small></span></div>):<p class="empty-copy">No purchase cost has been entered.</p>}</section>
|
||||
<section id="equivalencies" class="workspace-panel entity-tab-panel" data-panel="equivalencies"><h2>UoM Equivalency</h2><p class="panel-intro">Define equivalent weight, volume, or count measurements for this ingredient.</p><div class="equivalency-list">{densities.map(x=><p><strong>{number(x.volume_quantity)} {unitById.get(x.volume_unit_id)?.symbol??x.volume_unit_id}</strong><span>=</span><strong>{number(x.mass_quantity)} {unitById.get(x.mass_unit_id)?.symbol??x.mass_unit_id}</strong><small>{x.state||"Density"} · sourced density</small></p>)}{conversionRows.map(x=><div class="equivalency-row"><p><strong>{number(x.from_quantity)} {unitById.get(x.from_unit_id)?.symbol??x.from_unit_id}</strong><span>=</span><strong>{number(x.to_quantity)} {unitById.get(x.to_unit_id)?.symbol??x.to_unit_id}</strong><small>{x.state||"Conversion"} · {x.isManual?"manual":x.source.title||"sourced"}</small></p>{editing&&x.isManual&&<div class="equivalency-actions"><button type="button" class="edit-equivalency" data-id={x.id} data-from-quantity={x.from_quantity} data-from-unit={x.from_unit_id} data-to-quantity={x.to_quantity} data-to-unit={x.to_unit_id} data-state={x.state??""}>Edit</button><form method="post" data-confirm-message="Remove this equivalency?"><input type="hidden" name="conversion_id" value={x.id}/><button name="intent" value="delete_conversion">Delete</button></form></div>}</div>)}</div>{!densities.length&&!conversions.length&&<p class="empty-copy">No equivalencies have been defined.</p>}{editing&&<details class="inline-editor equivalency-editor"><summary>+ Add Equivalency</summary><form method="post" class="inline-form"><input type="hidden" name="conversion_id" value=""/><label><span>From amount</span><input name="from_quantity" type="number" min="0.0001" step="any" value="1"/></label><label><span>From unit</span><select name="from_unit_id">{units.map(x=><option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label><i>=</i><label><span>To amount</span><input name="to_quantity" type="number" min="0.0001" step="any" required/></label><label><span>To unit</span><select name="to_unit_id">{units.map(x=><option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label><label><span>Preparation state</span><input name="state" placeholder="e.g. chopped"/></label><button name="intent" value="conversion">Add</button><button type="button" class="cancel-equivalency" hidden>Cancel</button></form></details>}</section>
|
||||
<section id="nutrition" class="workspace-panel entity-tab-panel" data-panel="nutrition"><h2>Nutrition</h2><p class="panel-intro">Nutrition values are sourced from the ingredient's mapped USDA FoodData Central record.</p>{editing&&<form method="post" class="usda-id-form"><label><span>USDA FoodData Central ID</span><input name="fdc_id" inputmode="numeric" pattern="[0-9]+" required value={usdaMapping?.source.external_id ?? ""}/></label><button name="intent" value="usda_mapping">Update from USDA</button>{usdaMapping?.sourceUrl&&<a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">Open USDA ↗</a>}</form>}{usdaMapping?<article class="ingredient-nutrition"><div class="nutrition-mapping-head"><div><strong>{usdaMapping.source.title}</strong><small>{usdaMapping.status} · USDA FoodData Central</small></div>{usdaMapping.sourceUrl&&<a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">View source ↗</a>}</div>{usdaMapping.nutrients.length?<dl>{usdaMapping.nutrients.map((entry:[string,number])=>{const[key,value]=entry;const[label,unit]=nutrientLabels[key]??[key.replaceAll("_"," "),""];return <div><dt>{label}</dt><dd>{number(value)} {unit}</dd></div>})}</dl>:<p class="notice">No reviewed nutrient values stored.</p>}<footer>{usdaMapping.source.external_id&&<span>Record {usdaMapping.source.external_id}</span>}{usdaMapping.source.retrieved_at&&<span>Retrieved {usdaMapping.source.retrieved_at}</span>}</footer></article>:<p class="empty-copy">No USDA record mapped.{editing&&" Enter an FDC ID above."}</p>}</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="recipe-read-right ingredient-read-right">
|
||||
<div class="recipe-workspace-tabs recipe-read-tabs entity-tabs" aria-label="Ingredient details">
|
||||
<button class="mobile-only-tab active" type="button" data-tab="overview"><Fragment set:html={recipeTabIcon("overview")}/><span class="recipe-tab-label">Details</span></button>
|
||||
<button type="button" data-tab="costs"><Fragment set:html={recipeTabIcon("costing")}/><span class="recipe-tab-label">Cost</span></button>
|
||||
<button type="button" data-tab="equivalencies"><Fragment set:html={recipeTabIcon("equivalencies")}/><span class="recipe-tab-label">UoM Equivalency</span></button>
|
||||
<button type="button" data-tab="nutrition"><Fragment set:html={recipeTabIcon("nutrition")}/><span class="recipe-tab-label">Nutrition</span></button>
|
||||
</div>
|
||||
|
||||
<div class="recipe-view-details ingredient-view-details">
|
||||
<section id="costs" class="workspace-panel entity-tab-panel active" data-panel="costs">
|
||||
<h2>Ingredient Cost</h2>
|
||||
<p class="panel-intro">Purchase packages, usable yield, and current prices.</p>
|
||||
{editing ? (
|
||||
<div class="ingredient-cost-edit">
|
||||
<details class="purchase-editor add-purchase" open={purchases.length === 0}>
|
||||
<summary>+ Add purchase item</summary>
|
||||
<PurchaseItemForm units={units}/>
|
||||
</details>
|
||||
{purchases.map(x => (
|
||||
<section class="ingredient-purchase-form">
|
||||
<header>
|
||||
<h3>{x.name}</h3>
|
||||
<form method="post" data-confirm-message="Remove this purchase item and its price history?">
|
||||
<input type="hidden" name="purchase_item_id" value={x.id}/>
|
||||
<button name="intent" value="delete_purchase_item">Remove</button>
|
||||
</form>
|
||||
</header>
|
||||
<PurchaseItemForm item={x} units={units}/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : purchases.length ? (
|
||||
purchases.map(x => (
|
||||
<div class="ingredient-cost-row">
|
||||
<span>
|
||||
<strong>{x.name}</strong>
|
||||
<small>{x.supplier_id || "No supplier"} · {x.status}</small>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{x.latest_price != null ? `${x.latest_currency} ${Number(x.latest_price).toFixed(2)}` : "No price"}</strong>
|
||||
<small>{number(x.package_quantity)} {unitById.get(x.package_unit_id)?.symbol ?? x.package_unit_id}{x.units_per_case > 1 ? ` × ${x.units_per_case}` : ""}</small>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p class="empty-copy">No purchase cost has been entered.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section id="equivalencies" class="workspace-panel entity-tab-panel" data-panel="equivalencies">
|
||||
<h2>U of M Equivalency</h2>
|
||||
<p class="panel-intro"><strong>XX Weight = XX Volume = XX Each</strong><br>You can define a custom ingredient conversion from weight to volume and to a pc/each of the ingredient.</p>
|
||||
<div class="equivalency-list">
|
||||
{densities.map(x => (
|
||||
<p>
|
||||
<strong>{number(x.volume_quantity)} {unitById.get(x.volume_unit_id)?.symbol ?? x.volume_unit_id}</strong>
|
||||
<span>=</span>
|
||||
<strong>{number(x.mass_quantity)} {unitById.get(x.mass_unit_id)?.symbol ?? x.mass_unit_id}</strong>
|
||||
<small>{x.state || "Density"} · sourced density</small>
|
||||
</p>
|
||||
))}
|
||||
{conversionRows.map(x => (
|
||||
<div class="equivalency-row">
|
||||
<p>
|
||||
<strong>{number(x.from_quantity)} {unitById.get(x.from_unit_id)?.symbol ?? x.from_unit_id}</strong>
|
||||
<span>=</span>
|
||||
<strong>{number(x.to_quantity)} {unitById.get(x.to_unit_id)?.symbol ?? x.to_unit_id}</strong>
|
||||
<small>{x.state || "Conversion"} · {x.isManual ? "manual" : x.source.title || "sourced"}</small>
|
||||
</p>
|
||||
{editing && x.isManual && (
|
||||
<div class="equivalency-actions">
|
||||
<button type="button" class="edit-equivalency" data-id={x.id} data-from-quantity={x.from_quantity} data-from-unit={x.from_unit_id} data-to-quantity={x.to_quantity} data-to-unit={x.to_unit_id} data-state={x.state ?? ""}>Edit</button>
|
||||
<form method="post" data-confirm-message="Remove this equivalency?">
|
||||
<input type="hidden" name="conversion_id" value={x.id}/>
|
||||
<button name="intent" value="delete_conversion">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!densities.length && !conversions.length && <p class="empty-copy">No equivalencies have been defined.</p>}
|
||||
{editing && (
|
||||
<details class="inline-editor equivalency-editor">
|
||||
<summary>+ Add Equivalency</summary>
|
||||
<form method="post" class="inline-form">
|
||||
<input type="hidden" name="conversion_id" value=""/>
|
||||
<label><span>From amount</span><input name="from_quantity" type="number" min="0.0001" step="any" value="1"/></label>
|
||||
<label><span>From unit</span><select name="from_unit_id">{units.map(x => <option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label>
|
||||
<i>=</i>
|
||||
<label><span>To amount</span><input name="to_quantity" type="number" min="0.0001" step="any" required/></label>
|
||||
<label><span>To unit</span><select name="to_unit_id">{units.map(x => <option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label>
|
||||
<label><span>Preparation state</span><input name="state" placeholder="e.g. chopped"/></label>
|
||||
<button name="intent" value="conversion">Add</button>
|
||||
<button type="button" class="cancel-equivalency" hidden>Cancel</button>
|
||||
</form>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section id="nutrition" class="workspace-panel entity-tab-panel" data-panel="nutrition">
|
||||
<h2>Nutrition</h2>
|
||||
<p class="panel-intro">Nutrition values are sourced from the ingredient's mapped USDA FoodData Central record.</p>
|
||||
{editing && (
|
||||
<form method="post" class="usda-id-form">
|
||||
<label>
|
||||
<span>USDA FoodData Central ID</span>
|
||||
<input name="fdc_id" inputmode="numeric" pattern="[0-9]+" required value={usdaMapping?.source.external_id ?? ""}/>
|
||||
</label>
|
||||
<button name="intent" value="usda_mapping">Update from USDA</button>
|
||||
{usdaMapping?.sourceUrl && <a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">Open USDA ↗</a>}
|
||||
</form>
|
||||
)}
|
||||
{usdaMapping ? (
|
||||
<article class="ingredient-nutrition">
|
||||
<div class="nutrition-mapping-head">
|
||||
<div>
|
||||
<strong>{usdaMapping.source.title}</strong>
|
||||
<small>{usdaMapping.status} · USDA FoodData Central</small>
|
||||
</div>
|
||||
{usdaMapping.sourceUrl && <a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">View source ↗</a>}
|
||||
</div>
|
||||
{usdaMapping.nutrients.length ? (
|
||||
<dl>
|
||||
{usdaMapping.nutrients.map((entry: [string, number]) => {
|
||||
const [key, value] = entry;
|
||||
const [label, unit] = nutrientLabels[key] ?? [key.replaceAll("_", " "), ""];
|
||||
return <div><dt>{label}</dt><dd>{number(value)} {unit}</dd></div>;
|
||||
})}
|
||||
</dl>
|
||||
) : (
|
||||
<p class="notice">No reviewed nutrient values stored.</p>
|
||||
)}
|
||||
<footer>
|
||||
{usdaMapping.source.external_id && <span>Record {usdaMapping.source.external_id}</span>}
|
||||
{usdaMapping.source.retrieved_at && <span>Retrieved {usdaMapping.source.retrieved_at}</span>}
|
||||
</footer>
|
||||
</article>
|
||||
) : (
|
||||
<p class="empty-copy">No USDA record mapped.{editing && " Enter an FDC ID above."}</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{editing&&<script is:inline>
|
||||
{editing && <script is:inline>
|
||||
const ingredientForm=document.querySelector('#ingredient-identity-form'),doneButton=document.querySelector('#ingredient-done'),prepRows=document.querySelector('#ingredient-prep-rows'),prepJson=document.querySelector('#ingredient-prep-json');
|
||||
let ingredientDirty=false,ingredientSubmitting=false;
|
||||
const setIngredientDirty=(value=true)=>{ingredientDirty=value;doneButton?.classList.toggle('dirty',value)};
|
||||
@@ -185,7 +489,25 @@ database.close();
|
||||
const rows=[...prepRows.querySelectorAll('.prep-edit-row')],values=rows.map(row=>({action_id:row.querySelector('.prep-row-action').value,yield_percent:Number(row.querySelector('.prep-row-yield').value),notes:row.querySelector('.prep-row-notes').value.trim()})),ids=values.map(row=>row.action_id);
|
||||
if(new Set(ids).size!==ids.length){alert('Each prep action can only appear once.');return}if(values.some(row=>!Number.isFinite(row.yield_percent)||row.yield_percent<=0)){alert('Each prep yield must be greater than 0%.');return}prepJson.value=JSON.stringify(values);if(!ingredientForm.reportValidity())return;doneButton.disabled=true;doneButton.textContent='Saving…';ingredientForm.requestSubmit();
|
||||
});
|
||||
window.addEventListener('beforeunload',event=>{if(ingredientDirty&&!ingredientSubmitting)event.preventDefault()});
|
||||
</script>}
|
||||
<script is:inline>document.querySelectorAll('form[data-confirm-message]').forEach(form=>form.addEventListener('submit',event=>{if(!confirm(form.dataset.confirmMessage))event.preventDefault()}));document.querySelectorAll('.entity-tabs button').forEach((button)=>button.addEventListener('click',()=>{document.querySelectorAll('.entity-tabs button').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.entity-tab-panel').forEach(x=>x.classList.remove('active'));button.classList.add('active');document.querySelector(`[data-panel="${button.dataset.tab}"]`)?.classList.add('active');}));const hash=location.hash.slice(1);if(hash)document.querySelector(`[data-tab="${hash}"]`)?.click();</script>
|
||||
window.addEventListener('beforeunload',event=>{if(ingredientDirty&&!ingredientSubmitting)event.preventDefault()});
|
||||
</script>
|
||||
}
|
||||
<script is:inline>
|
||||
document.querySelectorAll('form[data-confirm-message]').forEach(form=>form.addEventListener('submit',event=>{if(!confirm(form.dataset.confirmMessage))event.preventDefault()}));
|
||||
function switchIngredientTab(tabName) {
|
||||
document.querySelectorAll('.entity-tabs button').forEach(x=>x.classList.toggle('active', x.dataset.tab === tabName));
|
||||
document.querySelectorAll('.entity-tab-panel').forEach(x=>x.classList.toggle('active', x.dataset.panel === tabName));
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
}
|
||||
document.querySelectorAll('.entity-tabs button').forEach((button)=>button.addEventListener('click',()=>{
|
||||
switchIngredientTab(button.dataset.tab);
|
||||
}));
|
||||
const hash=location.hash.slice(1);
|
||||
if(hash) {
|
||||
const target = document.querySelector(`[data-tab="${hash}"]`);
|
||||
if (target) target.click();
|
||||
} else if (window.innerWidth <= 900) {
|
||||
switchIngredientTab('overview');
|
||||
}
|
||||
</script>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import { openDatabase } from "../../../../lib/database";
|
||||
import {
|
||||
getInventoryCountDetail,
|
||||
saveInventoryCountItems,
|
||||
} from "../../../../lib/repository/inventory-repository";
|
||||
import { readOnlyMode } from "../../../../lib/runtime";
|
||||
|
||||
const id = Astro.params.id!;
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Astro.redirect("/app/?error=database-missing", 303);
|
||||
|
||||
let error = "";
|
||||
if (Astro.request.method === "POST") {
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
const intent = String(form.get("intent") ?? "save");
|
||||
|
||||
if (intent === "save" || intent === "complete" || intent === "reopen") {
|
||||
const ingredientIds = form.getAll("ingredient_id").map(String);
|
||||
const locationIds = form.getAll("location_id").map((v) => String(v) || null);
|
||||
const quantities = form.getAll("quantity").map((v) => Number(v) || 0);
|
||||
const unitIds = form.getAll("unit_id").map(String);
|
||||
|
||||
const items = ingredientIds.map((ingId, idx) => ({
|
||||
ingredient_id: ingId,
|
||||
location_id: locationIds[idx] ?? null,
|
||||
quantity: quantities[idx] ?? 0,
|
||||
unit_id: unitIds[idx] ?? "gram",
|
||||
}));
|
||||
|
||||
const newStatus =
|
||||
intent === "complete"
|
||||
? "completed"
|
||||
: intent === "reopen"
|
||||
? "open"
|
||||
: undefined;
|
||||
|
||||
saveInventoryCountItems(database, id, items, newStatus);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/inventory/${id}/`, 303);
|
||||
}
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "Unable to save count sheet.";
|
||||
}
|
||||
}
|
||||
|
||||
const count = getInventoryCountDetail(database, id);
|
||||
if (!count) {
|
||||
database.close();
|
||||
return new Response("Inventory count session not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch all available units and active ingredients for the quick-add selector
|
||||
const allUnits = database.prepare("SELECT id, name, symbol FROM units ORDER BY name").all() as Array<{ id: string; name: string; symbol: string }>;
|
||||
const activeIngredients = database.prepare("SELECT id, name FROM ingredients WHERE status='active' AND deleted_at IS NULL ORDER BY name").all() as Array<{ id: string; name: string }>;
|
||||
|
||||
database.close();
|
||||
|
||||
const selectedLocation = Astro.url.searchParams.get("loc") ?? "all";
|
||||
const filteredItems = count.items.filter((item) => {
|
||||
if (selectedLocation === "all") return true;
|
||||
if (selectedLocation === "unassigned") return !item.location_id;
|
||||
return item.location_id === selectedLocation;
|
||||
});
|
||||
|
||||
const isCompleted = count.status === "completed";
|
||||
---
|
||||
|
||||
<BaseLayout title={count.title} immersive>
|
||||
<div class="count-sheet-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="count-sheet-workspace">
|
||||
{error && <div class="notice count-notice">{error}</div>}
|
||||
|
||||
<header class="count-sheet-header">
|
||||
<div class="count-sheet-header-left">
|
||||
<nav class="count-breadcrumbs">
|
||||
<a href="/app/inventory/">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>Inventory counts</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="count-title-row">
|
||||
<h1>{count.title}</h1>
|
||||
<span class={`status-badge ${count.status}`}>
|
||||
{isCompleted ? "Completed" : "Open Draft"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="count-sheet-meta">
|
||||
<span>Count Date: <strong>{count.counted_at}</strong></span>
|
||||
{count.notes && <span>· {count.notes}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="count-sheet-header-right">
|
||||
<div class="total-valuation-pill">
|
||||
<span class="val-label">Total On-Hand Valuation</span>
|
||||
<strong class="val-amount font-mono" id="header-total-value">
|
||||
${count.total_value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
{!readOnlyMode && (
|
||||
<div class="count-header-actions">
|
||||
{isCompleted ? (
|
||||
<form method="post" class="inline-action-form">
|
||||
<input type="hidden" name="intent" value="reopen" />
|
||||
<button type="submit" class="secondary-btn">Reopen Count</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" class="secondary-btn" id="save-draft-trigger">
|
||||
Save Draft
|
||||
</button>
|
||||
<button type="button" class="complete-btn" id="complete-count-trigger">
|
||||
✓ Complete Count
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Location Filter Tabs -->
|
||||
<nav class="location-tabs" aria-label="Filter count sheet by location">
|
||||
<a
|
||||
href={`/app/inventory/${id}/`}
|
||||
class:list={["loc-tab", { active: selectedLocation === "all" }]}
|
||||
>
|
||||
<span>All Locations</span>
|
||||
<small>{count.items.length}</small>
|
||||
</a>
|
||||
{count.locations.map((loc) => {
|
||||
const locItemCount = count.items.filter((i) => i.location_id === loc.id).length;
|
||||
return (
|
||||
<a
|
||||
href={`/app/inventory/${id}/?loc=${loc.id}`}
|
||||
class:list={["loc-tab", { active: selectedLocation === loc.id }]}
|
||||
>
|
||||
<span>{loc.name}</span>
|
||||
<small>{locItemCount}</small>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<!-- Count Sheet Table Form -->
|
||||
<form method="post" id="count-sheet-form">
|
||||
<input type="hidden" name="intent" id="form-intent" value="save" />
|
||||
|
||||
<div class="count-table-container">
|
||||
<table class="count-items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-ing">Ingredient</th>
|
||||
<th class="col-loc">Location</th>
|
||||
<th class="col-qty">Quantity</th>
|
||||
<th class="col-unit">Unit</th>
|
||||
<th class="col-cost">Unit Cost</th>
|
||||
<th class="col-ext">Extended Value</th>
|
||||
{!isCompleted && !readOnlyMode && <th class="col-del"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="count-table-body">
|
||||
{filteredItems.map((item) => (
|
||||
<tr class="count-item-row" data-unit-cost={item.unit_cost ?? 0}>
|
||||
<td class="col-ing">
|
||||
<input type="hidden" name="ingredient_id" value={item.ingredient_id} />
|
||||
<strong>{item.ingredient_name}</strong>
|
||||
</td>
|
||||
<td class="col-loc">
|
||||
{isCompleted || readOnlyMode ? (
|
||||
<span>{item.location_name ?? "Unassigned"}</span>
|
||||
) : (
|
||||
<select name="location_id" class="table-loc-select">
|
||||
<option value="">(Unassigned)</option>
|
||||
{count.locations.map((loc) => (
|
||||
<option value={loc.id} selected={loc.id === item.location_id}>
|
||||
{loc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td class="col-qty">
|
||||
{isCompleted || readOnlyMode ? (
|
||||
<span class="font-mono">{item.quantity}</span>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
name="quantity"
|
||||
min="0"
|
||||
step="any"
|
||||
value={item.quantity}
|
||||
class="qty-input font-mono"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td class="col-unit">
|
||||
{isCompleted || readOnlyMode ? (
|
||||
<span>{item.unit_symbol}</span>
|
||||
) : (
|
||||
<select name="unit_id" class="table-unit-select">
|
||||
{allUnits.map((u) => (
|
||||
<option value={u.id} selected={u.id === item.unit_id}>
|
||||
{u.symbol || u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td class="col-cost font-mono">
|
||||
{item.unit_cost != null ? (
|
||||
`$${item.unit_cost.toFixed(4)}`
|
||||
) : (
|
||||
<span class="unpriced">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="col-ext font-mono row-extended-value">
|
||||
${(item.extended_cost ?? 0).toFixed(2)}
|
||||
</td>
|
||||
{!isCompleted && !readOnlyMode && (
|
||||
<td class="col-del">
|
||||
<button
|
||||
type="button"
|
||||
class="row-delete-btn"
|
||||
title="Remove from count"
|
||||
onclick="this.closest('tr').remove(); updateLiveTotals();"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filteredItems.length === 0 && (
|
||||
<div class="empty-location-notice">
|
||||
<p>No items assigned to this storage location in this count session.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCompleted && !readOnlyMode && (
|
||||
<div class="add-item-bar">
|
||||
<select id="quick-add-select" class="quick-add-dropdown">
|
||||
<option value="">+ Add item to count sheet…</option>
|
||||
{activeIngredients
|
||||
.filter((ing) => !count.items.some((i) => i.ingredient_id === ing.id))
|
||||
.map((ing) => (
|
||||
<option value={ing.id}>{ing.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" id="quick-add-btn" class="quick-add-btn">
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script is:inline>
|
||||
function updateLiveTotals() {
|
||||
let total = 0;
|
||||
document.querySelectorAll(".count-item-row").forEach((row) => {
|
||||
const unitCost = parseFloat(row.getAttribute("data-unit-cost") || "0");
|
||||
const qtyInput = row.querySelector(".qty-input");
|
||||
const qty = qtyInput ? parseFloat(qtyInput.value || "0") : 0;
|
||||
const ext = unitCost * qty;
|
||||
const extEl = row.querySelector(".row-extended-value");
|
||||
if (extEl) extEl.textContent = `$${ext.toFixed(2)}`;
|
||||
total += ext;
|
||||
});
|
||||
const headerEl = document.getElementById("header-total-value");
|
||||
if (headerEl) headerEl.textContent = `$${total.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll(".qty-input").forEach((input) => {
|
||||
input.addEventListener("input", updateLiveTotals);
|
||||
});
|
||||
|
||||
const saveTrigger = document.getElementById("save-draft-trigger");
|
||||
if (saveTrigger) {
|
||||
saveTrigger.addEventListener("click", () => {
|
||||
const form = document.getElementById("count-sheet-form");
|
||||
const intent = document.getElementById("form-intent");
|
||||
if (form && intent) {
|
||||
intent.value = "save";
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const completeTrigger = document.getElementById("complete-count-trigger");
|
||||
if (completeTrigger) {
|
||||
completeTrigger.addEventListener("click", () => {
|
||||
if (confirm("Complete and finalize this inventory count? On-hand valuations will be finalized.")) {
|
||||
const form = document.getElementById("count-sheet-form");
|
||||
const intent = document.getElementById("form-intent");
|
||||
if (form && intent) {
|
||||
intent.value = "complete";
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const addBtn = document.getElementById("quick-add-btn");
|
||||
const selectEl = document.getElementById("quick-add-select");
|
||||
if (addBtn && selectEl) {
|
||||
addBtn.addEventListener("click", () => {
|
||||
const selectedId = selectEl.value;
|
||||
const selectedName = selectEl.options[selectEl.selectedIndex]?.text;
|
||||
if (!selectedId) return;
|
||||
|
||||
const tbody = document.getElementById("count-table-body");
|
||||
if (tbody) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "count-item-row";
|
||||
tr.setAttribute("data-unit-cost", "0");
|
||||
tr.innerHTML = `
|
||||
<td class="col-ing">
|
||||
<input type="hidden" name="ingredient_id" value="${selectedId}" />
|
||||
<strong>${selectedName}</strong>
|
||||
</td>
|
||||
<td class="col-loc">
|
||||
<select name="location_id" class="table-loc-select">
|
||||
<option value="">(Unassigned)</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="col-qty">
|
||||
<input type="number" name="quantity" min="0" step="any" value="0" class="qty-input font-mono" required />
|
||||
</td>
|
||||
<td class="col-unit">
|
||||
<select name="unit_id" class="table-unit-select">
|
||||
<option value="gram">g</option>
|
||||
<option value="each">ea</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="col-cost font-mono">—</td>
|
||||
<td class="col-ext font-mono row-extended-value">$0.00</td>
|
||||
<td class="col-del">
|
||||
<button type="button" class="row-delete-btn" onclick="this.closest('tr').remove(); updateLiveTotals();">×</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
tr.querySelector(".qty-input")?.addEventListener("input", updateLiveTotals);
|
||||
selectEl.remove(selectEl.selectedIndex);
|
||||
selectEl.value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.count-sheet-workspace {
|
||||
padding: 72px max(24px, calc((100vw - 1160px) / 2)) 80px;
|
||||
}
|
||||
.count-sheet-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.count-breadcrumbs a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.count-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.count-title-row h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
margin: 0;
|
||||
}
|
||||
.count-sheet-meta {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.count-sheet-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.total-valuation-pill {
|
||||
background: #f1f5fe;
|
||||
border: 1px solid #dbe4ff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.val-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.val-amount {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--blue);
|
||||
}
|
||||
.count-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.secondary-btn {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #d0d6e4;
|
||||
background: white;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #050841;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.secondary-btn:hover {
|
||||
border-color: #050841;
|
||||
background: #f8faff;
|
||||
}
|
||||
.complete-btn {
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
background: #3d5df6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.complete-btn:hover {
|
||||
background: #2b4be0;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.status-badge.open {
|
||||
background: #fff8e6;
|
||||
color: #b25e00;
|
||||
border: 1px solid #ffd599;
|
||||
}
|
||||
.status-badge.completed {
|
||||
background: #e6f9f3;
|
||||
color: #0d8262;
|
||||
border: 1px solid #a3ebd4;
|
||||
}
|
||||
|
||||
/* Location tabs */
|
||||
.location-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.loc-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: white;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.loc-tab.active {
|
||||
background: #050841;
|
||||
color: white;
|
||||
border-color: #050841;
|
||||
}
|
||||
.loc-tab small {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.loc-tab.active small {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.count-table-container {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.count-items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.count-items-table th,
|
||||
.count-items-table td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
}
|
||||
.count-items-table th {
|
||||
background: #ffffff;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.count-items-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.col-ing {
|
||||
width: 30%;
|
||||
}
|
||||
.col-loc {
|
||||
width: 20%;
|
||||
}
|
||||
.col-qty {
|
||||
width: 15%;
|
||||
}
|
||||
.col-unit {
|
||||
width: 12%;
|
||||
}
|
||||
.col-cost {
|
||||
width: 11%;
|
||||
text-align: right;
|
||||
}
|
||||
.col-ext {
|
||||
width: 12%;
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
}
|
||||
.col-del {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.qty-input {
|
||||
width: 90px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.table-loc-select,
|
||||
.table-unit-select {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.row-delete-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 18px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.row-delete-btn:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
.unpriced {
|
||||
color: var(--muted);
|
||||
}
|
||||
.empty-location-notice {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Add item bar */
|
||||
.add-item-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.quick-add-dropdown {
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background: white;
|
||||
}
|
||||
.quick-add-btn {
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
background: white;
|
||||
border: 1px solid #050841;
|
||||
border-radius: 999px;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #050841;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.quick-add-btn:hover {
|
||||
color: #3d5df6;
|
||||
border-color: #3d5df6;
|
||||
background: #f1f5fe;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.count-sheet-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.count-sheet-header-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,519 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import { openDatabase } from "../../../../lib/database";
|
||||
import {
|
||||
createInventoryCount,
|
||||
getInventoryCounts,
|
||||
getInventoryLocations,
|
||||
} from "../../../../lib/repository/inventory-repository";
|
||||
import { readOnlyMode } from "../../../../lib/runtime";
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Astro.redirect("/app/?error=database-missing", 303);
|
||||
|
||||
let error = "";
|
||||
if (Astro.request.method === "POST") {
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
const intent = String(form.get("intent") ?? "create");
|
||||
|
||||
if (intent === "create") {
|
||||
const title = String(form.get("title") ?? "").trim();
|
||||
const counted_at = String(form.get("counted_at") ?? "").trim();
|
||||
const notes = String(form.get("notes") ?? "").trim();
|
||||
const prepopulate = form.get("prepopulate") === "1";
|
||||
|
||||
if (!title) throw new Error("Title is required.");
|
||||
if (!counted_at) throw new Error("Count date is required.");
|
||||
|
||||
const createdId = createInventoryCount(database, {
|
||||
title,
|
||||
counted_at,
|
||||
notes,
|
||||
prepopulate,
|
||||
});
|
||||
|
||||
database.close();
|
||||
return Astro.redirect(`/app/inventory/${createdId}/`, 303);
|
||||
}
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "Unable to create inventory count.";
|
||||
}
|
||||
}
|
||||
|
||||
const counts = getInventoryCounts(database);
|
||||
const locations = getInventoryLocations(database);
|
||||
const totalInventoryValue = counts
|
||||
.filter((c) => c.status === "completed")
|
||||
.reduce((sum, c) => sum + c.total_value, 0);
|
||||
|
||||
database.close();
|
||||
---
|
||||
|
||||
<BaseLayout title="Inventory" immersive>
|
||||
<div class="inventory-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="inventory-workspace">
|
||||
{error && <div class="notice inventory-notice">{error}</div>}
|
||||
|
||||
<header class="inventory-header">
|
||||
<div class="inventory-header-left">
|
||||
<nav class="inventory-breadcrumbs">
|
||||
<a href="/app/">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>All items</span>
|
||||
</a>
|
||||
</nav>
|
||||
<h1>Inventory Count Sessions</h1>
|
||||
<p class="inventory-subtitle">
|
||||
Sheet-to-shelf on-hand stock counts, valuations, and storage locations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!readOnlyMode && (
|
||||
<div class="inventory-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="primary-count-btn"
|
||||
onclick="document.getElementById('new-count-dialog').showModal()"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z" />
|
||||
</svg>
|
||||
<span>New Count Session</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div class="inventory-summary-cards">
|
||||
<article class="inv-stat-card">
|
||||
<span class="inv-stat-label">Total Count Sessions</span>
|
||||
<strong class="inv-stat-value">{counts.length}</strong>
|
||||
</article>
|
||||
<article class="inv-stat-card">
|
||||
<span class="inv-stat-label">Storage Locations</span>
|
||||
<strong class="inv-stat-value">{locations.length}</strong>
|
||||
</article>
|
||||
<article class="inv-stat-card highlight">
|
||||
<span class="inv-stat-label">Total Completed Valuation</span>
|
||||
<strong class="inv-stat-value">
|
||||
${totalInventoryValue.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{counts.length > 0 ? (
|
||||
<div class="inventory-table">
|
||||
<div class="inventory-table-head">
|
||||
<span class="head-col date">Count Date</span>
|
||||
<span class="head-col title">Session Title</span>
|
||||
<span class="head-col status">Status</span>
|
||||
<span class="head-col items">Items Counted</span>
|
||||
<span class="head-col value">Total Value</span>
|
||||
<span class="head-col actions"></span>
|
||||
</div>
|
||||
|
||||
<div class="inventory-table-body">
|
||||
{counts.map((count) => (
|
||||
<a href={`/app/inventory/${count.id}/`} class="inventory-table-row">
|
||||
<span class="col date">{count.counted_at}</span>
|
||||
<span class="col title">
|
||||
<strong>{count.title}</strong>
|
||||
{count.notes && <small>{count.notes}</small>}
|
||||
</span>
|
||||
<span class="col status">
|
||||
<span class={`status-badge ${count.status}`}>
|
||||
{count.status === "completed" ? "Completed" : "Open Draft"}
|
||||
</span>
|
||||
</span>
|
||||
<span class="col items">{count.item_count} items</span>
|
||||
<span class="col value font-mono">
|
||||
${count.total_value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span class="col actions">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty-inventory-state">
|
||||
<div class="empty-inv-icon">
|
||||
<svg viewBox="0 0 24 24" width="36" height="36" fill="currentColor">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 10h2v7H7zm4-3h2v10h-2zm4 6h2v4h-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>No inventory counts recorded yet</h2>
|
||||
<p>Create your first location-specific count session to begin tracking on-hand stock and valuations.</p>
|
||||
{!readOnlyMode && (
|
||||
<button
|
||||
type="button"
|
||||
class="primary-count-btn"
|
||||
onclick="document.getElementById('new-count-dialog').showModal()"
|
||||
>
|
||||
Start First Count Session
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- New Count Session Modal Dialog -->
|
||||
<dialog id="new-count-dialog" class="count-modal-dialog">
|
||||
<form method="post">
|
||||
<input type="hidden" name="intent" value="create" />
|
||||
<div class="dialog-header">
|
||||
<h2>New Inventory Count Session</h2>
|
||||
<button type="button" class="close-btn" onclick="this.closest('dialog').close()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
<label class="form-field">
|
||||
<span>Session Title *</span>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
required
|
||||
placeholder="e.g. Month-End Count - August 2026"
|
||||
value={`Count - ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span>Count Date *</span>
|
||||
<input
|
||||
type="date"
|
||||
name="counted_at"
|
||||
required
|
||||
value={new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span>Notes / Shift (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
name="notes"
|
||||
placeholder="e.g. Sunday night post-service count"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="checkbox-field">
|
||||
<input type="checkbox" name="prepopulate" value="1" checked />
|
||||
<span>Pre-populate all active catalog ingredients with 0 count</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="cancel-btn" onclick="this.closest('dialog').close()">Cancel</button>
|
||||
<button type="submit" class="submit-btn">Create Count Sheet</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.inventory-workspace {
|
||||
padding: 72px max(24px, calc((100vw - 1160px) / 2)) 80px;
|
||||
}
|
||||
.inventory-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.inventory-breadcrumbs a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.inventory-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.inventory-subtitle {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.primary-count-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
background: #3d5df6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.primary-count-btn:hover {
|
||||
background: #2b4be0;
|
||||
}
|
||||
.inventory-summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.inv-stat-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.inv-stat-card.highlight {
|
||||
background: #f8faff;
|
||||
border-color: #dbe4ff;
|
||||
}
|
||||
.inv-stat-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.inv-stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
.inventory-table {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.inventory-table-head,
|
||||
.inventory-table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px minmax(180px, 1fr) 130px 140px 140px 40px;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
.inventory-table-head {
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.inventory-table-row {
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.inventory-table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.inventory-table-row:hover {
|
||||
background: #f8faff;
|
||||
}
|
||||
.inventory-table-row .title strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
}
|
||||
.inventory-table-row .title small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.status-badge.open {
|
||||
background: #fff8e6;
|
||||
color: #b25e00;
|
||||
border: 1px solid #ffd599;
|
||||
}
|
||||
.status-badge.completed {
|
||||
background: #e6f9f3;
|
||||
color: #0d8262;
|
||||
border: 1px solid #a3ebd4;
|
||||
}
|
||||
.col.value {
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
.col.actions {
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.empty-inventory-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.empty-inv-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 16px;
|
||||
background: #f1f5fe;
|
||||
color: var(--blue);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.empty-inventory-state h2 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.empty-inventory-state p {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
max-width: 420px;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.count-modal-dialog {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
width: min(500px, calc(100vw - 32px));
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.count-modal-dialog::backdrop {
|
||||
background: rgba(5, 8, 65, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.dialog-header h2 {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
.close-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-field input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.checkbox-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
.dialog-footer {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.cancel-btn {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #8b93a7;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
.cancel-btn:hover {
|
||||
color: #050841;
|
||||
}
|
||||
.submit-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
background: #3d5df6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.submit-btn:hover {
|
||||
background: #2b4be0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.inventory-summary-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.inventory-table-head {
|
||||
display: none;
|
||||
}
|
||||
.inventory-table-row {
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
}
|
||||
.inventory-table-row .col.items,
|
||||
.inventory-table-row .col.date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,235 @@
|
||||
---
|
||||
export const prerender=false;
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import {readOnlyMode} from "../../../../lib/runtime";
|
||||
import {openDatabase,refreshSiteProjection} from "../../../../lib/database";
|
||||
const id=Astro.params.id!,editing=!readOnlyMode&&Astro.url.searchParams.get("edit")==="1";
|
||||
const database=openDatabase({readOnly:false});if(!database)return Astro.redirect("/app/?error=database-missing",303);
|
||||
let error="";
|
||||
if(Astro.request.method==="POST")try{const form=await Astro.request.formData(),intent=String(form.get("intent"));
|
||||
if(intent==="details"){const name=String(form.get("name")??"").trim();if(!name)throw new Error("Name is required.");database.prepare("UPDATE collections SET name=?,description=? WHERE id=?").run(name,String(form.get("description")??"").trim()||null,id);}
|
||||
if(intent==="membership"){const selected=new Set(form.getAll("recipe_id").map(String));database.exec("BEGIN IMMEDIATE");try{database.prepare("DELETE FROM collection_recipes WHERE collection_id=?").run(id);const insert=database.prepare("INSERT INTO collection_recipes(collection_id,recipe_id,position) VALUES (?,?,?)");[...selected].forEach((recipeId,index)=>insert.run(id,recipeId,index+1));database.exec("COMMIT");}catch(cause){database.exec("ROLLBACK");throw cause;}}
|
||||
refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipe-books/${id}/`,303);
|
||||
}catch(cause){error=cause instanceof Error?cause.message:"Unable to save recipe book.";}
|
||||
const book=database.prepare("SELECT * FROM collections WHERE id=? AND deleted_at IS NULL").get(id) as any;if(!book){database.close();return new Response("Recipe book not found",{status:404});}
|
||||
const recipes=database.prepare("SELECT r.id,r.title,cr.position,cr.recipe_id IS NOT NULL included FROM recipes r LEFT JOIN collection_recipes cr ON cr.recipe_id=r.id AND cr.collection_id=? WHERE r.deleted_at IS NULL ORDER BY coalesce(cr.position,999999),r.title").all(id) as any[];
|
||||
import { readOnlyMode } from "../../../../lib/runtime";
|
||||
import { openDatabase, refreshSiteProjection } from "../../../../lib/database";
|
||||
|
||||
const id = Astro.params.id!;
|
||||
const editing = !readOnlyMode && Astro.url.searchParams.get("edit") === "1";
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Astro.redirect("/app/?error=database-missing", 303);
|
||||
|
||||
let error = "";
|
||||
if (Astro.request.method === "POST") {
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
const intent = String(form.get("intent") ?? "save");
|
||||
|
||||
if (intent === "save" || intent === "details") {
|
||||
const name = String(form.get("name") ?? "").trim();
|
||||
const description = String(form.get("description") ?? "").trim() || null;
|
||||
if (!name) throw new Error("Name is required.");
|
||||
database.prepare("UPDATE collections SET name=?, description=? WHERE id=?").run(name, description, id);
|
||||
}
|
||||
|
||||
if (intent === "save" || intent === "membership") {
|
||||
const selected = new Set(form.getAll("recipe_id").map(String));
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
database.prepare("DELETE FROM collection_recipes WHERE collection_id=?").run(id);
|
||||
const insert = database.prepare("INSERT INTO collection_recipes(collection_id, recipe_id, position) VALUES (?, ?, ?)");
|
||||
[...selected].forEach((recipeId, index) => insert.run(id, recipeId, index + 1));
|
||||
database.exec("COMMIT");
|
||||
} catch (cause) {
|
||||
database.exec("ROLLBACK");
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
refreshSiteProjection(database);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipe-books/${id}/`, 303);
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "Unable to save recipe book.";
|
||||
}
|
||||
}
|
||||
|
||||
const book = database.prepare("SELECT * FROM collections WHERE id=? AND deleted_at IS NULL").get(id) as any;
|
||||
if (!book) {
|
||||
database.close();
|
||||
return new Response("Recipe book not found", { status: 404 });
|
||||
}
|
||||
|
||||
const recipes = database.prepare(`
|
||||
SELECT r.id, r.title, cr.position, (cr.recipe_id IS NOT NULL) AS included
|
||||
FROM recipes r
|
||||
LEFT JOIN collection_recipes cr ON cr.recipe_id = r.id AND cr.collection_id = ?
|
||||
WHERE r.deleted_at IS NULL
|
||||
ORDER BY coalesce(cr.position, 999999), r.title
|
||||
`).all(id) as any[];
|
||||
|
||||
const includedRecipes = recipes.filter((r) => r.included);
|
||||
database.close();
|
||||
---
|
||||
<BaseLayout title={book.name} immersive><section class="entity-detail-shell recipe-book-detail"><DetailUtility/><header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=book">← Recipe books</a></p><h1>{book.name}</h1><p>{recipes.filter(x=>x.included).length} recipes</p></div>{!readOnlyMode&&<a class="edit-command" href={editing?`/app/recipe-books/${id}/`:`/app/recipe-books/${id}/?edit=1`}>{editing?"✓ Done":"✎ Edit"}</a>}</header>{error&&<p class="notice">{error}</p>}<main class="book-workspace">{editing&&<form method="post" class="book-details-form"><input type="hidden" name="intent" value="details"/><label><span>Name</span><input name="name" value={book.name} required/></label><label><span>Description</span><input name="description" value={book.description??""}/></label><button>Save details</button></form>}<form method="post" class:list={["book-membership",{"read-only":!editing}]}><input type="hidden" name="intent" value="membership"/><header><div><h2>Recipes</h2><p>{editing?"Choose the recipes included in this book.":book.description}</p></div>{editing&&<button>Save recipes</button>}</header>{recipes.filter(recipe=>editing||recipe.included).map(recipe=><label class="book-recipe-row">{editing&&<input type="checkbox" name="recipe_id" value={recipe.id} checked={recipe.included}/>}<span class="workspace-pill-icon recipe">▦</span><a href={`/app/recipes/${recipe.id}/`}><strong>{recipe.title}</strong></a></label>)}</form></main></section></BaseLayout>
|
||||
|
||||
<BaseLayout title={book.name} immersive>
|
||||
<div class="recipe-book-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="recipe-book-workspace">
|
||||
{error && <div class="notice book-notice">{error}</div>}
|
||||
|
||||
<header class="recipe-book-header">
|
||||
<div class="recipe-book-header-left">
|
||||
<nav class="recipe-book-breadcrumbs">
|
||||
<a href="/app/?type=book">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>Recipe books</span>
|
||||
</a>
|
||||
</nav>
|
||||
<h1>{book.name}</h1>
|
||||
<div class="recipe-book-meta">
|
||||
<span class="recipe-book-count-badge">
|
||||
<span class="count-number">{includedRecipes.length}</span> {includedRecipes.length === 1 ? "recipe" : "recipes"}
|
||||
</span>
|
||||
{book.description && <span class="recipe-book-desc">{book.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnlyMode && (
|
||||
<div class="recipe-book-header-actions">
|
||||
<a
|
||||
class:list={["book-action-btn", { active: editing }]}
|
||||
href={editing ? `/app/recipe-books/${id}/` : `/app/recipe-books/${id}/?edit=1`}
|
||||
>
|
||||
{editing ? (
|
||||
<>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
<span>Cancel</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
<span>Edit</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{editing ? (
|
||||
<form method="post" class="book-edit-form">
|
||||
<input type="hidden" name="intent" value="save" />
|
||||
|
||||
<section class="book-edit-card">
|
||||
<h2>Book Details</h2>
|
||||
<div class="book-field-group">
|
||||
<label>
|
||||
<span class="field-label">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={book.name}
|
||||
required
|
||||
placeholder="e.g. Signature Cocktails"
|
||||
class="book-input"
|
||||
autofocus
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="field-label">Description (optional)</span>
|
||||
<textarea
|
||||
name="description"
|
||||
rows="2"
|
||||
placeholder="Add context or notes about this recipe collection..."
|
||||
class="book-textarea"
|
||||
>{book.description ?? ""}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="book-edit-card">
|
||||
<div class="book-edit-card-header">
|
||||
<div>
|
||||
<h2>Select Recipes</h2>
|
||||
<p class="section-subtitle">
|
||||
Choose the recipes included in this book ({includedRecipes.length} currently selected)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="book-recipe-checklist">
|
||||
{recipes.map((recipe) => (
|
||||
<label class="book-checklist-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="recipe_id"
|
||||
value={recipe.id}
|
||||
checked={recipe.included}
|
||||
class="book-checkbox"
|
||||
/>
|
||||
<span class="book-recipe-icon">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z" transform="scale(1.1, 1.1) translate(4px, 3.5px)"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="book-checklist-label">
|
||||
<strong>{recipe.title}</strong>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="book-edit-actions">
|
||||
<button type="submit" class="book-save-btn">Save changes</button>
|
||||
<a href={`/app/recipe-books/${id}/`} class="book-cancel-link">Cancel</a>
|
||||
</footer>
|
||||
</form>
|
||||
) : (
|
||||
<section class="book-view-section">
|
||||
{includedRecipes.length > 0 ? (
|
||||
<div class="book-directory-table">
|
||||
<div class="book-directory-toolbar">
|
||||
<span class="book-toolbar-title">Included Recipes</span>
|
||||
<span class="book-toolbar-count">{includedRecipes.length} {includedRecipes.length === 1 ? "recipe" : "recipes"}</span>
|
||||
</div>
|
||||
<div class="book-recipe-list">
|
||||
{includedRecipes.map((recipe) => (
|
||||
<div class="book-recipe-row">
|
||||
<span class="book-recipe-icon">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z" transform="scale(1.1, 1.1) translate(4px, 3.5px)"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="book-recipe-info">
|
||||
<a href={`/app/recipes/${recipe.id}/`} class="book-recipe-title">
|
||||
{recipe.title}
|
||||
</a>
|
||||
</div>
|
||||
<a href={`/app/recipes/${recipe.id}/`} class="book-recipe-arrow" aria-label={`View ${recipe.title}`}>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="book-empty-state">
|
||||
<span class="empty-icon">▣</span>
|
||||
<h3>No recipes in this book yet</h3>
|
||||
<p>Organize your recipes by adding them to this book.</p>
|
||||
{!readOnlyMode && (
|
||||
<a href={`/app/recipe-books/${id}/?edit=1`} class="book-add-recipes-btn">
|
||||
✎ Add recipes
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,8 +1,93 @@
|
||||
---
|
||||
export const prerender=false;
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import { openDatabase,refreshSiteProjection } from "../../../../lib/database";
|
||||
let error:string|undefined;
|
||||
if(Astro.request.method==="POST"){const database=openDatabase({readOnly:false});if(!database)return Astro.redirect("/app/?error=database-missing",303);try{const form=await Astro.request.formData();const name=String(form.get("name")??"").trim();const description=String(form.get("description")??"").trim()||null;if(!name)throw new Error("Name is required.");const base=name.toLocaleLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g,"_").replace(/^_|_$/g,"")||"recipe_book";let id=base,suffix=2;while(database.prepare("SELECT 1 FROM collections WHERE id=?").get(id))id=`${base}_${suffix++}`;database.prepare("INSERT INTO collections(id,name,description,source_json) VALUES (?,?,?,'{}')").run(id,name,description);refreshSiteProjection(database);database.close();return Astro.redirect("/app/?type=book",303);}catch(cause){error=cause instanceof Error?cause.message:"Unable to create recipe book.";database.close();}}
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import { openDatabase, refreshSiteProjection } from "../../../../lib/database";
|
||||
|
||||
let error: string | undefined;
|
||||
|
||||
if (Astro.request.method === "POST") {
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Astro.redirect("/app/?error=database-missing", 303);
|
||||
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
const name = String(form.get("name") ?? "").trim();
|
||||
const description = String(form.get("description") ?? "").trim() || null;
|
||||
if (!name) throw new Error("Name is required.");
|
||||
|
||||
const base = name.toLocaleLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "") || "recipe_book";
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (database.prepare("SELECT 1 FROM collections WHERE id=?").get(id)) {
|
||||
id = `${base}_${suffix++}`;
|
||||
}
|
||||
|
||||
database.prepare("INSERT INTO collections(id, name, description, source_json) VALUES (?, ?, ?, '{}')").run(id, name, description);
|
||||
refreshSiteProjection(database);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipe-books/${id}/`, 303);
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "Unable to create recipe book.";
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
---
|
||||
<BaseLayout title="New recipe book"><section class="shell page-heading"><p class="eyebrow"><a href="/app/?type=book">Recipe books</a></p><h1>New recipe book</h1><p>Create a collection for organizing recipes.</p></section><section class="shell create-entity"><form method="post" class="editor-form"><fieldset><legend>Recipe book details</legend>{error&&<div class="notice">{error}</div>}<label><span>Name</span><input name="name" required autofocus /></label><label><span>Description</span><textarea name="description" rows="4"></textarea></label></fieldset><div class="editor-actions"><button>Create recipe book</button><a href="/app/?type=book">Cancel</a></div></form></section></BaseLayout>
|
||||
|
||||
<BaseLayout title="New recipe book" immersive>
|
||||
<div class="recipe-book-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="recipe-book-workspace">
|
||||
{error && <div class="notice book-notice">{error}</div>}
|
||||
|
||||
<header class="recipe-book-header">
|
||||
<div class="recipe-book-header-left">
|
||||
<nav class="recipe-book-breadcrumbs">
|
||||
<a href="/app/?type=book">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>Recipe books</span>
|
||||
</a>
|
||||
</nav>
|
||||
<h1>New recipe book</h1>
|
||||
<p class="recipe-book-subtitle">Create a collection for organizing recipes.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form method="post" class="book-edit-form">
|
||||
<section class="book-edit-card">
|
||||
<h2>Recipe Book Details</h2>
|
||||
<div class="book-field-group">
|
||||
<label>
|
||||
<span class="field-label">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
autofocus
|
||||
placeholder="e.g. Pastry & Bakes, Cocktail Program"
|
||||
class="book-input"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="field-label">Description (optional)</span>
|
||||
<textarea
|
||||
name="description"
|
||||
rows="3"
|
||||
placeholder="Describe what belongs in this recipe book..."
|
||||
class="book-textarea"
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="book-edit-actions">
|
||||
<button type="submit" class="book-save-btn">Create recipe book</button>
|
||||
<a href="/app/?type=book" class="book-cancel-link">Cancel</a>
|
||||
</footer>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import { databaseProjection, duplicateRecipe, editableRecipe, openDatabase, refreshSiteProjection, saveRecipeMetadata } from "../../../../lib/database";
|
||||
import { recipeStructure } from "../../../../lib/database";
|
||||
import {
|
||||
editableRecipe,
|
||||
openDatabase,
|
||||
refreshSiteProjection,
|
||||
saveRecipeMetadata,
|
||||
duplicateRecipe,
|
||||
recipeStructure,
|
||||
} from "../../../../lib/database";
|
||||
import { getRecipeCalculationContext } from "../../../../lib/repository";
|
||||
import RecipeStructureEditor from "../../../../components/RecipeStructureEditor";
|
||||
import RecipeCalculator, { LiveCostValues, LiveNutritionValues } from "../../../../components/RecipeCalculator";
|
||||
import { calculateNutrition } from "../../../../lib/nutrition";
|
||||
import { calculateCost } from "../../../../lib/costing";
|
||||
import type { Ingredient, PrepAction, PurchaseItem, Recipe, SourceMapping, Unit } from "../../../../lib/types";
|
||||
import type { Ingredient, Recipe } from "../../../../lib/types";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import { convertWithIngredientMeasures } from "../../../../lib/measurement";
|
||||
import { readOnlyMode } from "../../../../lib/runtime";
|
||||
@@ -21,22 +28,18 @@ let error: string | undefined;
|
||||
if (Astro.request.method === "POST") {
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
if(form.get("intent")==="media"){
|
||||
const url=String(form.get("url")??"").trim(),mediaType=String(form.get("media_type")??"image"),stepId=String(form.get("step_id")??"").trim()||null;if(!url)throw new Error("Media URL is required.");if(!["image","video"].includes(mediaType))throw new Error("Invalid media type.");
|
||||
const position=(database.prepare("SELECT coalesce(max(position),0)+1 position FROM recipe_media WHERE recipe_id=?").get(id) as any).position;database.prepare("INSERT INTO recipe_media(recipe_id,id,step_id,media_type,url,caption,position) VALUES (?,?,?,?,?,?,?)").run(id,`media_${Date.now()}`,stepId,mediaType,url,String(form.get("caption")??"").trim()||null,position);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#additional`,303);
|
||||
}
|
||||
if(form.get("intent")==="additional"){
|
||||
const row=database.prepare("SELECT source_json FROM recipes WHERE id=?").get(id) as any,source=JSON.parse(row.source_json??"{}");
|
||||
const shelfQuantity=Number(form.get("shelf_quantity")),shelfUnit=String(form.get("shelf_unit")??"").trim();
|
||||
if(shelfQuantity>0&&shelfUnit)source.shelf_life={duration:{quantity:shelfQuantity,unit_id:shelfUnit},storage_condition:String(form.get("storage_condition")??"").trim()||undefined};else delete source.shelf_life;
|
||||
const notes=String(form.get("notes")??"").split("\n").map(value=>value.trim()).filter(Boolean);
|
||||
database.prepare("UPDATE recipes SET station=?,cover_media_url=?,notes_json=?,source_json=? WHERE id=?").run(String(form.get("station")??"").trim()||null,String(form.get("cover_media_url")??"").trim()||null,JSON.stringify(notes),JSON.stringify(source),id);
|
||||
database.prepare("UPDATE recipes SET station=?,notes_json=?,source_json=? WHERE id=?").run(String(form.get("station")??"").trim()||null,JSON.stringify(notes),JSON.stringify(source),id);
|
||||
refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#additional`,303);
|
||||
}
|
||||
if (form.get("intent") === "duplicate") {
|
||||
const redirectTo = `/app/recipes/${duplicateRecipe(database, id)}/?edit=1`;
|
||||
const duplicatedId = duplicateRecipe(database, id);
|
||||
database.close();
|
||||
return Astro.redirect(redirectTo, 303);
|
||||
return Astro.redirect(`/app/recipes/${duplicatedId}/?edit=1`, 303);
|
||||
}
|
||||
if(form.get("intent")==="auto_yield"){
|
||||
const current=editableRecipe(database,id);if(!current)throw new Error("Recipe not found.");
|
||||
@@ -47,34 +50,53 @@ if (Astro.request.method === "POST") {
|
||||
if(original?.quantity>0&&original?.unit_id)saveRecipeMetadata(database,id,current.save_version,{title:current.title,summary:current.summary,categories_json:current.categories_json,tags_json:current.tags_json,yield_quantity:original.quantity,yield_unit_id:original.unit_id,yield_servings:current.yield_servings,yield_basis:original.basis??null});
|
||||
delete source.auto_yield_original;database.prepare("UPDATE recipes SET auto_yield=0,source_json=? WHERE id=?").run(JSON.stringify(source),id);refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1&saved=1`,303);
|
||||
}
|
||||
const projectionNow=databaseProjection(database) as {recipes:Recipe[];ingredients:Ingredient[];units:Unit[];sourceMappings:SourceMapping[]};
|
||||
const recipesNow=new Map(projectionNow.recipes.map(value=>[value.id,value])),ingredientsNow=new Map(projectionNow.ingredients.map(value=>[value.id,value])),unitsNow=new Map(projectionNow.units.map(value=>[value.id,value]));
|
||||
const autoRecipe=recipesNow.get(id)!;let calculatedYieldWeightG=0;const conversionFailures:string[]=[];
|
||||
for(const item of autoRecipe.components.flatMap(component=>component.items).filter(item=>!item.optional)){
|
||||
try{if("ingredient_id" in item.reference){const ingredient=ingredientsNow.get(item.reference.ingredient_id);if(!ingredient)throw new Error("ingredient not found");calculatedYieldWeightG+=convertWithIngredientMeasures(item.amount,"gram",ingredient,unitsNow).quantity;}else{const child=recipesNow.get(item.reference.recipe_id);if(!child)throw new Error("sub-recipe not found");calculatedYieldWeightG+=convertWithIngredientMeasures(item.amount,"gram",{schema_version:2,id:child.id,name:child.title,status:"active",categories:[],measure_conversions:child.measure_conversions},unitsNow).quantity;}}catch{conversionFailures.push("ingredient_id" in item.reference?ingredientsNow.get(item.reference.ingredient_id)?.name??item.reference.ingredient_id:recipesNow.get(item.reference.recipe_id)?.title??item.reference.recipe_id);}
|
||||
const calcContextNow = getRecipeCalculationContext(database, id);
|
||||
if (!calcContextNow) throw new Error("Recipe not found.");
|
||||
const { recipes: recipesNow, ingredients: ingredientsNow, units: unitsNow, domainRecipe: autoRecipe } = calcContextNow;
|
||||
let calculatedYieldWeightG = 0;
|
||||
const conversionFailures: string[] = [];
|
||||
for (const item of autoRecipe.components.flatMap(component => component.items).filter(item => !item.optional)) {
|
||||
try {
|
||||
if ("ingredient_id" in item.reference) {
|
||||
const ingredient = ingredientsNow.get(item.reference.ingredient_id);
|
||||
if (!ingredient) throw new Error("ingredient not found");
|
||||
calculatedYieldWeightG += convertWithIngredientMeasures(item.amount, "gram", ingredient, unitsNow).quantity;
|
||||
} else {
|
||||
const child = recipesNow.get(item.reference.recipe_id);
|
||||
if (!child) throw new Error("sub-recipe not found");
|
||||
calculatedYieldWeightG += convertWithIngredientMeasures(item.amount, "gram", { schema_version: 2, id: child.id, name: child.title, status: "active", categories: [], measure_conversions: child.measure_conversions }, unitsNow).quantity;
|
||||
}
|
||||
} catch {
|
||||
conversionFailures.push("ingredient_id" in item.reference ? ingredientsNow.get(item.reference.ingredient_id)?.name ?? item.reference.ingredient_id : recipesNow.get(item.reference.recipe_id)?.title ?? item.reference.recipe_id);
|
||||
}
|
||||
}
|
||||
if(conversionFailures.length)throw new Error(`Auto calculate total yield needs a weight equivalency for: ${conversionFailures.join(", ")}.`);
|
||||
if(!(calculatedYieldWeightG>0))throw new Error("No convertible ingredient weights are available for automatic yield.");
|
||||
source.auto_yield_original={quantity:current.yield_quantity,unit_id:current.yield_unit_id,basis:current.yield_basis};
|
||||
saveRecipeMetadata(database,id,current.save_version,{title:current.title,summary:current.summary,categories_json:current.categories_json,tags_json:current.tags_json,yield_quantity:calculatedYieldWeightG,yield_unit_id:"gram",yield_servings:current.yield_servings,yield_basis:"theoretical"});
|
||||
database.prepare("UPDATE recipes SET auto_yield=1,source_json=? WHERE id=?").run(JSON.stringify(source),id);refreshSiteProjection(database);
|
||||
database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1&saved=1#nutrition`,303);
|
||||
if (conversionFailures.length) throw new Error(`Auto calculate total yield needs a weight equivalency for: ${conversionFailures.join(", ")}.`);
|
||||
if (!(calculatedYieldWeightG > 0)) throw new Error("No convertible ingredient weights are available for automatic yield.");
|
||||
source.auto_yield_original = { quantity: current.yield_quantity, unit_id: current.yield_unit_id, basis: current.yield_basis };
|
||||
saveRecipeMetadata(database, id, current.save_version, { title: current.title, summary: current.summary, categories_json: current.categories_json, tags_json: current.tags_json, yield_quantity: calculatedYieldWeightG, yield_unit_id: "gram", yield_servings: current.yield_servings, yield_basis: "theoretical" });
|
||||
database.prepare("UPDATE recipes SET auto_yield=1,source_json=? WHERE id=?").run(JSON.stringify(source), id);
|
||||
refreshSiteProjection(database);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipes/${id}/?edit=1&saved=1#nutrition`, 303);
|
||||
}
|
||||
if (form.get("intent") === "nutrition_servings") {
|
||||
const current=editableRecipe(database,id);if(!current)throw new Error("Recipe not found.");
|
||||
const yieldServings=Number(form.get("yield_servings"));
|
||||
if(!Number.isFinite(yieldServings)||yieldServings<=0)throw new Error("Servings must be greater than zero.");
|
||||
const expectedVersion=Number(form.get("save_version"));
|
||||
saveRecipeMetadata(database,id,expectedVersion,{title:current.title,summary:current.summary,categories_json:current.categories_json,tags_json:current.tags_json,yield_quantity:current.yield_quantity,yield_unit_id:current.yield_unit_id,yield_servings:yieldServings,yield_basis:current.yield_basis});
|
||||
database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#nutrition`,303);
|
||||
const current = editableRecipe(database, id);
|
||||
if (!current) throw new Error("Recipe not found.");
|
||||
const yieldServings = Number(form.get("yield_servings"));
|
||||
if (!Number.isFinite(yieldServings) || yieldServings <= 0) throw new Error("Servings must be greater than zero.");
|
||||
const expectedVersion = Number(form.get("save_version"));
|
||||
saveRecipeMetadata(database, id, expectedVersion, { title: current.title, summary: current.summary, categories_json: current.categories_json, tags_json: current.tags_json, yield_quantity: current.yield_quantity, yield_unit_id: current.yield_unit_id, yield_servings: yieldServings, yield_basis: current.yield_basis });
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipes/${id}/?edit=1#nutrition`, 303);
|
||||
}
|
||||
if (form.get("intent") === "conversion") {
|
||||
const fromQuantity=Number(form.get("from_quantity")),toQuantity=Number(form.get("to_quantity"));
|
||||
const fromUnitId=String(form.get("from_unit_id")??""),toUnitId=String(form.get("to_unit_id")??"");
|
||||
if(!(fromQuantity>0&&toQuantity>0)) throw new Error("Equivalency quantities must be positive.");
|
||||
if(!database.prepare("SELECT 1 FROM units WHERE id IN (?,?) HAVING count(*)=2").get(fromUnitId,toUnitId)) throw new Error("Unknown equivalency unit.");
|
||||
database.prepare("INSERT INTO recipe_measure_conversions VALUES (?,?,?,?,?,?,?,?)").run(id,`manual_${Date.now()}`,fromQuantity,fromUnitId,toQuantity,toUnitId,String(form.get("notes")??"").trim()||null,JSON.stringify({source_type:"manual",title:"Recipe application",reviewed:true}));
|
||||
database.close(); return Astro.redirect(`/app/recipes/${id}/?edit=1#equivalencies`,303);
|
||||
const fromQuantity = Number(form.get("from_quantity")), toQuantity = Number(form.get("to_quantity"));
|
||||
const fromUnitId = String(form.get("from_unit_id") ?? ""), toUnitId = String(form.get("to_unit_id") ?? "");
|
||||
if (!(fromQuantity > 0 && toQuantity > 0)) throw new Error("Equivalency quantities must be positive.");
|
||||
if (!database.prepare("SELECT 1 FROM units WHERE id IN (?,?) HAVING count(*)=2").get(fromUnitId, toUnitId)) throw new Error("Unknown equivalency unit.");
|
||||
database.prepare("INSERT INTO recipe_measure_conversions VALUES (?,?,?,?,?,?,?,?)").run(id, `manual_${Date.now()}`, fromQuantity, fromUnitId, toQuantity, toUnitId, String(form.get("notes") ?? "").trim() || null, JSON.stringify({ source_type: "manual", title: "Recipe application", reviewed: true }));
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipes/${id}/?edit=1#equivalencies`, 303);
|
||||
}
|
||||
const title = String(form.get("title") ?? "").trim();
|
||||
const yieldQuantity = Number(form.get("yield_quantity"));
|
||||
@@ -107,26 +129,29 @@ const recipe = editableRecipe(database, id);
|
||||
if (!recipe) { database.close(); return new Response("Recipe not found", { status: 404 }); }
|
||||
const units = database.prepare("SELECT id, name, symbol, dimension FROM units ORDER BY dimension, name").all() as Array<{ id: string; name: string; symbol: string; dimension: string }>;
|
||||
const structure = recipeStructure(database, id)!;
|
||||
const autoYield=Boolean((database.prepare("SELECT auto_yield FROM recipes WHERE id=?").get(id) as {auto_yield:number}).auto_yield);
|
||||
const ingredientOptions = database.prepare("SELECT id, name FROM ingredients WHERE status = 'active' ORDER BY name").all() as Array<{ id: string; name: string }>;
|
||||
const autoYield = Boolean((database.prepare("SELECT auto_yield FROM recipes WHERE id=?").get(id) as { auto_yield: number }).auto_yield);
|
||||
const ingredientOptions = (database.prepare("SELECT id, name FROM ingredients WHERE status = 'active' ORDER BY name").all() as Array<{ id: string; name: string }>).map((ingredient) => ({
|
||||
...ingredient,
|
||||
aliases: (database.prepare("SELECT name FROM ingredient_aliases WHERE ingredient_id = ? ORDER BY name").all(ingredient.id) as Array<{ name: string }>).map((entry) => entry.name),
|
||||
}));
|
||||
const recipeOptions = database.prepare("SELECT id, title AS name FROM recipes WHERE deleted_at IS NULL ORDER BY title").all() as Array<{ id: string; name: string }>;
|
||||
const prepActionOptions = database.prepare("SELECT id, name FROM prep_actions ORDER BY name").all() as Array<{ id: string; name: string }>;
|
||||
const recipeConversions=database.prepare("SELECT * FROM recipe_measure_conversions WHERE recipe_id=? ORDER BY id").all(id) as any[];
|
||||
const additional=database.prepare("SELECT station,cover_media_url,notes_json,source_json FROM recipes WHERE id=?").get(id) as any;
|
||||
const additionalSource=JSON.parse(additional.source_json??"{}"),shelfLife=additionalSource.shelf_life;
|
||||
const media=database.prepare("SELECT * FROM recipe_media WHERE recipe_id=? ORDER BY position").all(id) as any[];
|
||||
const projection = databaseProjection(database) as { recipes:Recipe[]; ingredients:Ingredient[]; units:Unit[]; sourceMappings:SourceMapping[]; purchaseItems:PurchaseItem[]; prepActions:PrepAction[] };
|
||||
const domainRecipe = projection.recipes.find((entry) => entry.id === id)!;
|
||||
const recipeMap = new Map(projection.recipes.map((entry) => [entry.id, entry]));
|
||||
const ingredientMap = new Map(projection.ingredients.map((entry) => [entry.id, entry]));
|
||||
const unitMap = new Map(projection.units.map((entry) => [entry.id, entry]));
|
||||
const nutrition = calculateNutrition(domainRecipe, { recipes:recipeMap, ingredients:ingredientMap, units:unitMap, mappings:new Map(projection.sourceMappings.map((entry) => [entry.id, entry])) });
|
||||
const cost = calculateCost(domainRecipe, { recipes:recipeMap, ingredients:ingredientMap, units:unitMap, purchaseItems:new Map(projection.purchaseItems.map((entry) => [entry.id, entry])), prepActions:new Map(projection.prepActions.map((entry) => [entry.id, entry])) });
|
||||
const calculatorComponents = domainRecipe.components.map((component) => ({ ...component, items:component.items.map((item) => { const ingredient="ingredient_id" in item.reference ? ingredientMap.get(item.reference.ingredient_id) : undefined; const child="recipe_id" in item.reference ? recipeMap.get(item.reference.recipe_id) : undefined; return { ...item, basisMember:item.basis_member, label:ingredient?.name ?? child?.title ?? "Unknown", href:ingredient ? `/app/ingredients/${ingredient.id}/` : child ? `/app/recipes/${child.id}/` : undefined, measureConversions:ingredient?.measure_conversions ?? child?.measure_conversions ?? [] }; }) }));
|
||||
const percentSubjectEntries=domainRecipe.components.flatMap(component=>component.items).map(item=>{const ingredient="ingredient_id" in item.reference?ingredientMap.get(item.reference.ingredient_id):undefined,child="recipe_id" in item.reference?recipeMap.get(item.reference.recipe_id):undefined;return ingredient?[`ingredient:${ingredient.id}`,{key:`ingredient:${ingredient.id}`,value:ingredient}] as [string,{key:string,value:Ingredient|Recipe}]:[`recipe:${child?.id}`,{key:`recipe:${child?.id}`,value:child!}] as [string,{key:string,value:Ingredient|Recipe}];});
|
||||
const percentSubjects=[...new Map<string,{key:string,value:Ingredient|Recipe}>(percentSubjectEntries).values()];
|
||||
const weightRates=Object.fromEntries(percentSubjects.flatMap(subject=>projection.units.map(unit=>{try{return [`${subject.key}:${unit.id}`,convertWithIngredientMeasures({quantity:1,unit_id:unit.id},"gram",subject.value as Ingredient,unitMap).quantity];}catch{return [`${subject.key}:${unit.id}`,null];}})));
|
||||
const nutritionMappingMap=new Map(projection.sourceMappings.map(mapping=>[mapping.id,mapping]));
|
||||
const recipeConversions = database.prepare("SELECT * FROM recipe_measure_conversions WHERE recipe_id=? ORDER BY id").all(id) as any[];
|
||||
const additional = database.prepare("SELECT station,notes_json,source_json FROM recipes WHERE id=?").get(id) as any;
|
||||
const additionalSource = JSON.parse(additional.source_json ?? "{}"), shelfLife = additionalSource.shelf_life;
|
||||
|
||||
const calcContext = getRecipeCalculationContext(database, id);
|
||||
if (!calcContext) { database.close(); return new Response("Recipe calculation context not found", { status: 404 }); }
|
||||
const { domainRecipe, recipes: recipeMap, ingredients: ingredientMap, units: unitMap, purchaseItems: purchaseItemsMap, sourceMappings: sourceMappingsMap, prepActions: prepActionsMap } = calcContext;
|
||||
|
||||
const nutrition = calculateNutrition(domainRecipe, { recipes: recipeMap, ingredients: ingredientMap, units: unitMap, mappings: sourceMappingsMap });
|
||||
const cost = calculateCost(domainRecipe, { recipes: recipeMap, ingredients: ingredientMap, units: unitMap, purchaseItems: purchaseItemsMap, prepActions: prepActionsMap });
|
||||
const reviewedNutritionMappingIds = new Set(Array.from(sourceMappingsMap.values()).filter((mapping) => mapping.mapping_type === "nutrition" && mapping.status === "reviewed" && Object.keys(mapping.nutrition_per_100g ?? {}).length > 0).map((mapping) => mapping.id));
|
||||
const calculatorComponents = domainRecipe.components.map((component) => ({ ...component, items: component.items.map((item) => { const ingredient = "ingredient_id" in item.reference ? ingredientMap.get(item.reference.ingredient_id) : undefined; const child = "recipe_id" in item.reference ? recipeMap.get(item.reference.recipe_id) : undefined; const mapped = ingredient ? (ingredient.nutrition_mapping_ids ?? []).some((mappingId) => reviewedNutritionMappingIds.has(mappingId)) : true; return { ...item, basisMember: item.basis_member, label: ingredient?.name ?? child?.title ?? "Unknown", href: ingredient ? `/app/ingredients/${ingredient.id}/` : child ? `/app/recipes/${child.id}/` : undefined, attention: Boolean(ingredient && !mapped), attentionMessage: ingredient && !mapped ? "Nutrition mapping needed" : undefined, measureConversions: ingredient?.measure_conversions ?? child?.measure_conversions ?? [] }; }) }));
|
||||
const percentSubjectEntries = domainRecipe.components.flatMap(component => component.items).map(item => { const ingredient = "ingredient_id" in item.reference ? ingredientMap.get(item.reference.ingredient_id) : undefined, child = "recipe_id" in item.reference ? recipeMap.get(item.reference.recipe_id) : undefined; return ingredient ? [`ingredient:${ingredient.id}`, { key: `ingredient:${ingredient.id}`, value: ingredient }] as [string, { key: string, value: Ingredient | Recipe }] : [`recipe:${child?.id}`, { key: `recipe:${child?.id}`, value: child! }] as [string, { key: string, value: Ingredient | Recipe }]; });
|
||||
const percentSubjects = [...new Map<string, { key: string, value: Ingredient | Recipe }>(percentSubjectEntries).values()];
|
||||
const weightRates = Object.fromEntries(percentSubjects.flatMap(subject => Array.from(unitMap.values()).map(unit => { try { return [`${subject.key}:${unit.id}`, convertWithIngredientMeasures({ quantity: 1, unit_id: unit.id }, "gram", subject.value as Ingredient, unitMap).quantity]; } catch { return [`${subject.key}:${unit.id}`, null]; } })));
|
||||
const nutritionMappingMap = sourceMappingsMap;
|
||||
const nutritionIngredients=domainRecipe.components.flatMap(component=>component.items).filter(item=>!item.optional).map(item=>{
|
||||
if("ingredient_id" in item.reference){
|
||||
const ingredient=ingredientMap.get(item.reference.ingredient_id)!;
|
||||
@@ -143,42 +168,321 @@ database.close();
|
||||
const categories = JSON.parse(recipe.categories_json).join(", ");
|
||||
const tags = JSON.parse(recipe.tags_json).join(", ");
|
||||
const saved = Astro.url.searchParams.get("saved") === "1";
|
||||
const RECIPE_TAB_ICONS={
|
||||
formula:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z",
|
||||
method:"M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5m0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5m0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5M7 19h14v-2H7zm0-6h14v-2H7zm0-8v2h14V5z",
|
||||
costing:"M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4",
|
||||
equivalencies:"M19.4 3.3h-6.6v-.5c0-.4-.3-.7-.8-.7-.4 0-.8.3-.8.7v.5H4.6L0 14s.2 3.8 4.7 3.8S9.4 14 9.4 14L6.1 6.2h5.1v15.7h1.5V6.2h5.1L14.6 14s.2 3.8 4.7 3.8S24 14 24 14L19.4 3.3zM7.7 14H1.5l3.1-7.4L7.7 14zm8.5 0l3.1-7.4 3.1 7.4h-6.2z",
|
||||
nutrition:"M9.42859 2.37431L9.80926 2.82134L10.1899 2.37431C11.1674 1.22652 12.668 0.5 14.2234 0.5C16.9685 0.5 19.1185 2.64998 19.1185 5.3951C19.1185 7.0848 18.3631 8.65707 16.9325 10.4062C15.4961 12.1623 13.4317 14.0352 10.8957 16.3348L10.895 16.3354L9.80799 17.325L8.72319 16.345L8.72211 16.344L8.71119 16.3341C6.18062 14.0344 4.12043 12.1623 2.68618 10.4075C1.25541 8.6571 0.5 7.08481 0.5 5.3951C0.5 2.64998 2.64998 0.5 5.3951 0.5C6.95051 0.5 8.45117 1.22652 9.42859 2.37431ZM3.70568 10.127C5.0829 11.7363 7.04455 13.5134 9.36637 15.6157L9.45571 15.7051L9.80926 16.0586L10.1628 15.7051L10.2522 15.6157C12.574 13.5134 14.5356 11.7363 15.9128 10.127C17.287 8.52131 18.1567 6.99709 18.1567 5.3951C18.1567 3.1571 16.4614 1.46185 14.2234 1.46185C12.6415 1.46185 11.0895 2.39876 10.4049 3.77684H9.22149C8.52967 2.40009 6.97866 1.46185 5.3951 1.46185C3.1571 1.46185 1.46185 3.1571 1.46185 5.3951C1.46185 6.99709 2.3315 8.52131 3.70568 10.127Z"
|
||||
};
|
||||
const RECIPE_TAB_VIEWBOX={formula:"0 0 14 16",method:"0 0 24 24",costing:"0 0 24 24",equivalencies:"0 0 24 24",nutrition:"0 0 20 18"};
|
||||
const recipeTabIcon=(name:string)=>`<span class="recipe-tab-icon"><svg viewBox="${RECIPE_TAB_VIEWBOX[name as keyof typeof RECIPE_TAB_VIEWBOX]}" aria-hidden="true"><path fill="currentColor" d="${RECIPE_TAB_ICONS[name as keyof typeof RECIPE_TAB_ICONS]}"/></svg></span>`;
|
||||
---
|
||||
<BaseLayout title={editing?`Edit ${recipe.title}`:recipe.title} immersive>
|
||||
<section class="recipe-detail-shell">
|
||||
<DetailUtility section="Recipes" sectionHref="/app/?type=recipe" />
|
||||
<header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=recipe">← Recipes</a></p>{editing?<input class="editable-entity-title" name="title" value={recipe.title} form="recipe-details-form" aria-label="Recipe name" required/>:<h1>{recipe.title}</h1>}</div>{!readOnlyMode&&<div class="entity-header-actions">{editing?<button class="primary-command" id="recipe-done" type="button" data-view-url={`/app/recipes/${id}/`}>✓ Done</button>:<a class="edit-command" href={`/app/recipes/${id}/?edit=1`}>✎ Edit</a>}<details class="detail-actions-menu"><summary aria-label="Recipe actions">⋮</summary><div><form method="post"><button name="intent" value="duplicate">Duplicate recipe</button></form></div></details></div>}</header>
|
||||
<div class="recipe-workspace-tabs">{editing?<><button class="active" type="button" data-edit-recipe-tab="method">☷ Prep Method</button><button type="button" data-edit-recipe-tab="costing">$ Cost</button><button type="button" data-edit-recipe-tab="equivalencies">⚖ UoM Equivalency</button><button type="button" data-edit-recipe-tab="nutrition">♡ Nutrition</button></>:<><button class="active" type="button" data-recipe-tab="method">☷ Prep Method</button><button type="button" data-recipe-tab="costing">$ Cost</button><button type="button" data-recipe-tab="equivalencies">⚖ UoM Equivalency</button><button type="button" data-recipe-tab="nutrition">♡ Nutrition</button></>}</div>
|
||||
{editing?<><section class="recipe-overview-strip">
|
||||
<form method="post" id="recipe-details-form" class="editor-form recipe-overview-form">
|
||||
<input type="hidden" name="save_version" value={recipe.save_version} />
|
||||
{saved && <div class="success-notice">Changes saved.</div>}
|
||||
{error && <div class="notice">{error}</div>}
|
||||
<div class:list={["inline-yield-editor",{"auto-calculated":autoYield}]}><span>Finished Yield</span><label><input name="yield_quantity" type="number" min="0.0001" step="any" required value={recipe.yield_quantity} placeholder="Qty" aria-label="Finished yield quantity" readonly={autoYield}/></label><label><select name="yield_unit_id" aria-label="Finished yield unit" aria-disabled={autoYield}>{units.map((unit) => <option value={unit.id} selected={recipe.yield_unit_id === unit.id}>{unit.symbol}</option>)}</select></label><input type="hidden" name="yield_servings" value={recipe.yield_servings??""}/><input type="hidden" name="yield_basis" value={recipe.yield_basis??""}/>{autoYield&&<small>Calculated from convertible ingredient quantities</small>}</div>
|
||||
<input type="hidden" name="summary" value={recipe.summary??""}/><input type="hidden" name="categories" value={categories}/><input type="hidden" name="tags" value={tags}/>
|
||||
</form>
|
||||
<form method="post" class:list={["inline-auto-yield",{active:autoYield}]}><input type="hidden" name="intent" value="auto_yield"/><button class="toggle-button" aria-label={`${autoYield?"Disable":"Enable"} automatic total yield`}><i></i></button><span>Auto calculate total yield</span>{autoYield&&<small><b>Revert</b> to original and disable auto calculate</small>}</form>
|
||||
</section>
|
||||
<section id="structure" class="recipe-structure-workspace unified-recipe-editor"><RecipeStructureEditor client:load recipeId={recipe.id} initial={structure} ingredients={ingredientOptions} recipes={recipeOptions} units={units} prepActions={prepActionOptions} weightRates={weightRates} autoYield={autoYield} showMethod={true}/></section>
|
||||
<section id="costing" class="recipe-edit-tab-panel" data-edit-recipe-panel="costing"><LiveCostValues client:load cost={cost}/></section>
|
||||
<section id="equivalencies" class="recipe-equivalence-editor recipe-edit-tab-panel" data-edit-recipe-panel="equivalencies"><h2>UoM Equivalency</h2><p>Define how this finished recipe converts between weight, volume, and portions.</p>{recipeConversions.map(x=><div><strong>{x.from_quantity} {x.from_unit_id}</strong><span>=</span><strong>{x.to_quantity} {x.to_unit_id}</strong><small>{x.notes}</small></div>)}<form method="post"><input name="from_quantity" type="number" min="0.0001" step="any" value="1"/><select name="from_unit_id">{units.map(x=><option value={x.id}>{x.name}</option>)}</select><span>=</span><input name="to_quantity" type="number" min="0.0001" step="any"/><select name="to_unit_id">{units.map(x=><option value={x.id}>{x.name}</option>)}</select><input name="notes" placeholder="Notes"/><button name="intent" value="conversion">Add equivalency</button></form></section>
|
||||
<section id="nutrition" class="recipe-edit-nutrition recipe-edit-tab-panel" data-edit-recipe-panel="nutrition"><LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients} editable saveVersion={recipe.save_version}/></section>
|
||||
<section id="additional" class="recipe-additional-editor"><h2>Additional Details</h2><form id="recipe-additional-form"><label class="cover-media-field"><span>{additional.cover_media_url?"Replace Cover Image":"Add Cover Image"}</span>{additional.cover_media_url&&<img src={additional.cover_media_url} alt=""/>}<input form="recipe-additional-form" name="cover_media_url" type="url" value={additional.cover_media_url??""} placeholder="Paste image URL"/></label><fieldset><legend>Shelf Life</legend><input name="shelf_quantity" type="number" min="0" step="any" value={shelfLife?.duration?.quantity??""} placeholder="Qty"/><select name="shelf_unit"><option value="">Unit</option>{["hour","day","week","month"].map(unit=><option value={unit} selected={shelfLife?.duration?.unit_id===unit}>{unit}</option>)}</select><input name="storage_condition" value={shelfLife?.storage_condition??""} placeholder="Storage condition"/></fieldset><label><span>Station</span><input name="station" value={additional.station??""} placeholder="Station Name"/></label><label><span>Tags</span><input name="tags" value={tags} placeholder="Tag Name"/></label></form></section>
|
||||
</>:<><section id="structure" class="recipe-view-workspace"><div class="recipe-view-formula"><RecipeCalculator client:load components={calculatorComponents} units={Object.fromEntries(unitMap)} basisUnitId={domainRecipe.scaling?.basis_amount?.unit_id??domainRecipe.yield.amount.unit_id} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnitId={domainRecipe.yield.amount.unit_id} yieldConversions={domainRecipe.measure_conversions??[]} mode={domainRecipe.scaling?.mode??"amount"} nutrition={nutrition} cost={cost} servings={domainRecipe.yield.servings} showDerived={false}/></div><div class="recipe-view-details"><section class="recipe-view-method recipe-tab-panel active" data-recipe-panel="method"><h2>Prep Method <small>{domainRecipe.steps.length}</small></h2><ol>{domainRecipe.steps.map(step=><li class:list={{placeholder:step.instruction.startsWith("TODO:")}}><strong>{step.order}.</strong><span>{step.instruction}{media.filter(entry=>entry.step_id===step.id).map(entry=><figure class="step-media">{entry.media_type==="image"?<img src={entry.url} alt={entry.caption??""}/>:<video src={entry.url} controls/>}{entry.caption&&<figcaption>{entry.caption}</figcaption>}</figure>)}</span></li>)}</ol></section><section class="recipe-tab-panel recipe-view-equivalencies" data-recipe-panel="equivalencies"><h2>UoM Equivalency</h2>{recipeConversions.length?recipeConversions.map(x=><div><strong>{x.from_quantity} {x.from_unit_id}</strong><span>=</span><strong>{x.to_quantity} {x.to_unit_id}</strong><small>{x.notes}</small></div>):<p>No recipe-level equivalencies have been defined.</p>}</section><section id="costing" class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="costing"><LiveCostValues client:load cost={cost}/></section><section id="nutrition" class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="nutrition"><LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients}/></section></div></section><section class="recipe-additional-view">{additional.cover_media_url&&<figure><img src={additional.cover_media_url} alt="" loading="lazy"/></figure>}<div><h2>Additional details</h2>{additional.station&&<p><strong>Station</strong><span>{additional.station}</span></p>}{shelfLife&&<p><strong>Shelf life</strong><span>{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}</span></p>}{JSON.parse(additional.notes_json??"[]").length>0&&<ul>{JSON.parse(additional.notes_json).map((note:string)=><li>{note}</li>)}</ul>}</div></section></>}
|
||||
<section class="recipe-detail-shell recipe-read-shell">
|
||||
<DetailUtility section="Recipes" sectionHref="/app/?type=recipe">
|
||||
<div class="entity-header-actions">
|
||||
{editing ? (
|
||||
<button class="primary-command" id="recipe-done" type="button" data-view-url={`/app/recipes/${id}/`}>✓ Done</button>
|
||||
) : !readOnlyMode && (
|
||||
<a class="edit-command" href={`/app/recipes/${id}/?edit=1`}><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>Edit</a>
|
||||
)}
|
||||
<details class="detail-actions-menu">
|
||||
<summary aria-label="Recipe actions"><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg></summary>
|
||||
<div><form method="post"><button name="intent" value="duplicate">Duplicate recipe</button></form></div>
|
||||
</details>
|
||||
</div>
|
||||
</DetailUtility>
|
||||
|
||||
<div class="recipe-read-left">
|
||||
<header class="entity-detail-header">
|
||||
<div>
|
||||
{editing ? (
|
||||
<input class="editable-entity-title" name="title" value={recipe.title} form="recipe-details-form" aria-label="Recipe name" required/>
|
||||
) : (
|
||||
<h1>{recipe.title}</h1>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{editing && (
|
||||
<section class="recipe-overview-strip">
|
||||
<form method="post" id="recipe-details-form" class="editor-form recipe-overview-form">
|
||||
<input type="hidden" name="save_version" value={recipe.save_version} />
|
||||
{saved && <div class="success-notice">Changes saved.</div>}
|
||||
{error && <div class="notice">{error}</div>}
|
||||
<div class:list={["inline-yield-editor",{"auto-calculated":autoYield}]}>
|
||||
<span class="yield-title-label">Total Yield</span>
|
||||
<div class="yield-inputs-row">
|
||||
<label><input name="yield_quantity" type="number" min="0.0001" step="any" required value={recipe.yield_quantity} placeholder="Qty" aria-label="Finished yield quantity" readonly={autoYield}/></label>
|
||||
<label><select name="yield_unit_id" aria-label="Finished yield unit" aria-disabled={autoYield}>{units.map((unit) => <option value={unit.id} selected={recipe.yield_unit_id === unit.id}>{unit.symbol}</option>)}</select></label>
|
||||
</div>
|
||||
<input type="hidden" name="yield_servings" value={recipe.yield_servings??""}/>
|
||||
<input type="hidden" name="yield_basis" value={recipe.yield_basis??""}/>
|
||||
</div>
|
||||
<input type="hidden" name="summary" value={recipe.summary??""}/>
|
||||
<input type="hidden" name="categories" value={categories}/>
|
||||
<input type="hidden" name="tags" value={tags}/>
|
||||
</form>
|
||||
<form method="post" class:list={["inline-auto-yield",{active:autoYield}]}>
|
||||
<input type="hidden" name="intent" value="auto_yield"/>
|
||||
<button class="toggle-button" aria-label={`${autoYield?"Disable":"Enable"} automatic total yield`}><i></i></button>
|
||||
<span>Auto calculate total yield</span>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<section id="structure" class="recipe-structure-workspace unified-recipe-editor">
|
||||
<RecipeStructureEditor client:load recipeId={recipe.id} initial={structure} ingredients={ingredientOptions} recipes={recipeOptions} units={units} prepActions={prepActionOptions} weightRates={weightRates} autoYield={autoYield} showMethod={true}/>
|
||||
</section>
|
||||
) : (
|
||||
<section class="recipe-view-formula-pane recipe-tab-panel active" data-recipe-panel="formula">
|
||||
<div class="recipe-view-formula">
|
||||
<RecipeCalculator client:load components={calculatorComponents} units={Object.fromEntries(unitMap)} basisUnitId={domainRecipe.scaling?.basis_amount?.unit_id??domainRecipe.yield.amount.unit_id} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnitId={domainRecipe.yield.amount.unit_id} yieldConversions={domainRecipe.measure_conversions??[]} mode={domainRecipe.scaling?.mode??"amount"} nutrition={nutrition} cost={cost} servings={domainRecipe.yield.servings} showDerived={false} showPercentControls={false}/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="recipe-read-right">
|
||||
<div class="recipe-workspace-tabs recipe-read-tabs">
|
||||
<button class="mobile-only-tab active" type="button" data-recipe-tab="formula"><Fragment set:html={recipeTabIcon("formula")}/><span class="recipe-tab-label">Ingredients</span></button>
|
||||
<button type="button" data-recipe-tab="method"><Fragment set:html={recipeTabIcon("method")}/><span class="recipe-tab-label">Prep Method</span></button>
|
||||
<button type="button" data-recipe-tab="costing"><Fragment set:html={recipeTabIcon("costing")}/><span class="recipe-tab-label">Cost</span></button>
|
||||
<button type="button" data-recipe-tab="equivalencies"><Fragment set:html={recipeTabIcon("equivalencies")}/><span class="recipe-tab-label">UoM Equivalency</span></button>
|
||||
<button type="button" data-recipe-tab="nutrition"><Fragment set:html={recipeTabIcon("nutrition")}/><span class="recipe-tab-label">Nutrition</span></button>
|
||||
</div>
|
||||
|
||||
<section class="recipe-view-details-pane">
|
||||
<div class="recipe-view-details">
|
||||
{editing ? (
|
||||
<section class="recipe-tab-panel active" data-recipe-panel="method">
|
||||
<div id="recipe-method-editor-slot"></div>
|
||||
<section id="additional" class="recipe-additional-editor">
|
||||
<h2>Additional details</h2>
|
||||
<form id="recipe-additional-form">
|
||||
<fieldset>
|
||||
<legend>Shelf Life</legend>
|
||||
<input name="shelf_quantity" type="number" min="0" step="any" value={shelfLife?.duration?.quantity??""} placeholder="Qty"/>
|
||||
<select name="shelf_unit">
|
||||
<option value="">Unit</option>
|
||||
{["hour","day","week","month"].map(unit=><option value={unit} selected={shelfLife?.duration?.unit_id===unit}>{unit}</option>)}
|
||||
</select>
|
||||
<input name="storage_condition" value={shelfLife?.storage_condition??""} placeholder="Storage condition"/>
|
||||
</fieldset>
|
||||
<label><span>Station</span><input name="station" value={additional.station??""} placeholder="Station Name"/></label>
|
||||
<label><span>Tags</span><input name="tags" value={tags} placeholder="Tag Name"/></label>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
) : (
|
||||
<section class="recipe-view-method recipe-tab-panel active" data-recipe-panel="method">
|
||||
<h2>Prep Method <small>{domainRecipe.steps.filter(s => !s.instruction.trim().endsWith(":") && !/^\(.+\)$/.test(s.instruction.trim())).length || domainRecipe.steps.length}</small></h2>
|
||||
<ol class="method-steps-list">
|
||||
{(() => {
|
||||
let stepCount = 0;
|
||||
return domainRecipe.steps.map((step) => {
|
||||
const text = step.instruction.trim();
|
||||
const isHeading = text.endsWith(":");
|
||||
const isNote = /^\(.+\)$/.test(text);
|
||||
if (!isHeading && !isNote) {
|
||||
stepCount += 1;
|
||||
}
|
||||
return (
|
||||
<li class:list={["method-step-item", { "prep-heading": isHeading, "prep-note": isNote, placeholder: text.startsWith("TODO:") }]}>
|
||||
{isHeading ? (
|
||||
<h3 class="method-heading-text">{text}</h3>
|
||||
) : isNote ? (
|
||||
<p class="method-note-text">{text}</p>
|
||||
) : (
|
||||
<>
|
||||
<strong>{stepCount}.</strong>
|
||||
<span>{step.instruction}</span>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section class="recipe-tab-panel recipe-view-equivalencies" data-recipe-panel="equivalencies">
|
||||
<h2>U of M Equivalency</h2>
|
||||
<p class="uom-equation-title">XX Weight = XX Volume = XX Each</p>
|
||||
<p class="uom-helper-text">
|
||||
If you would like to use this recipe by weight, volume, and even by the portion - you can customize that here.
|
||||
<button class="uom-help-btn" type="button" aria-label="Help on UoM Equivalency">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 16h-2v-2h2v2zm1.07-7.75l-.9.92C12.45 11.9 12 12.5 12 14h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H7c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.04-.42 1.99-1.07 2.75z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<div class="uom-toggle-row">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="standard-conversion-toggle" checked />
|
||||
<i></i>
|
||||
</label>
|
||||
<div class="toggle-label-group">
|
||||
<strong>Standard Weight - Volume Conversion</strong>
|
||||
<small>When toggled on, conversions are locked to 8oz = 1 cup. Toggle off to customize.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uom-equation-row">
|
||||
<!-- Weight Group -->
|
||||
<div class="uom-equation-group">
|
||||
<span class="uom-group-label">Weight</span>
|
||||
<div class="uom-box-pair">
|
||||
<input
|
||||
type="number"
|
||||
id="uom-mass-qty"
|
||||
name="mass_conversion"
|
||||
class="uom-qty-input"
|
||||
value={domainRecipe.yield.amount.quantity || 95}
|
||||
placeholder="1"
|
||||
step="any"
|
||||
min="0.0001"
|
||||
aria-label="Weight quantity"
|
||||
/>
|
||||
<select id="uom-mass-unit" name="mass_unit" class="uom-unit-select" aria-label="Weight unit">
|
||||
{units.filter(u => u.dimension === "mass" || (!u.dimension && ["gram", "g", "oz", "ounce", "lb", "pound", "kg"].includes(u.id))).map(u => (
|
||||
<option value={u.id} selected={u.id === (domainRecipe.yield.amount.unit_id || "gram") || u.id === "gram" || u.symbol === "g"}>
|
||||
{u.symbol || u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equals Sign -->
|
||||
<div class="uom-equation-separator">=</div>
|
||||
|
||||
<!-- Volume Group -->
|
||||
<div class="uom-equation-group">
|
||||
<span class="uom-group-label">Volume</span>
|
||||
<div class="uom-box-pair">
|
||||
<input
|
||||
type="number"
|
||||
id="uom-volume-qty"
|
||||
name="volume_conversion"
|
||||
class="uom-qty-input"
|
||||
value="1"
|
||||
placeholder="1"
|
||||
step="any"
|
||||
min="0.0001"
|
||||
aria-label="Volume quantity"
|
||||
/>
|
||||
<select id="uom-volume-unit" name="volume_unit" class="uom-unit-select" aria-label="Volume unit">
|
||||
{units.filter(u => u.dimension === "volume" || (!u.dimension && ["cup", "fl_oz", "tbsp", "tsp", "ml", "liter", "l", "gallon", "quart", "pint"].includes(u.id))).map(u => (
|
||||
<option value={u.id} selected={u.id === "cup" || u.symbol === "cup"}>
|
||||
{u.symbol || u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equals Sign -->
|
||||
<div class="uom-equation-separator">=</div>
|
||||
|
||||
<!-- Each Group -->
|
||||
<div class="uom-equation-group">
|
||||
<span class="uom-group-label">Each</span>
|
||||
<div class="uom-box-pair">
|
||||
<input
|
||||
type="number"
|
||||
id="uom-container-qty"
|
||||
name="container_conversion"
|
||||
class="uom-qty-input"
|
||||
value={domainRecipe.yield.servings || 1}
|
||||
placeholder="1"
|
||||
step="any"
|
||||
min="0.0001"
|
||||
aria-label="Each quantity"
|
||||
/>
|
||||
<select id="uom-container-unit" name="container_unit" class="uom-unit-select" aria-label="Each unit">
|
||||
<option value="serving" selected>serving</option>
|
||||
<option value="portion">portion</option>
|
||||
<option value="each">each</option>
|
||||
<option value="ea.">ea.</option>
|
||||
{units.filter(u => u.dimension === "count").map(u => (
|
||||
<option value={u.id}>{u.symbol || u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recipeConversions.length > 0 && (
|
||||
<div class="uom-custom-conversions">
|
||||
<h3>Custom Equivalencies</h3>
|
||||
{recipeConversions.map(x => (
|
||||
<div class="uom-custom-row">
|
||||
<strong>{x.from_quantity} {unitMap.get(x.from_unit_id)?.symbol ?? x.from_unit_id}</strong>
|
||||
<span>=</span>
|
||||
<strong>{x.to_quantity} {unitMap.get(x.to_unit_id)?.symbol ?? x.to_unit_id}</strong>
|
||||
{x.notes && <small>{x.notes}</small>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="costing">
|
||||
<LiveCostValues client:load cost={cost} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnit={unitMap.get(domainRecipe.yield.amount.unit_id)?.symbol??domainRecipe.yield.amount.unit_id} editable={editing}/>
|
||||
</section>
|
||||
|
||||
<section class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="nutrition">
|
||||
<LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients} editable={editing} saveVersion={recipe.save_version}/>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!editing && (
|
||||
<section class="recipe-additional-view">
|
||||
<div>
|
||||
<h2>Additional details</h2>
|
||||
{additional.station&&<p><strong>Station</strong><span>{additional.station}</span></p>}
|
||||
{shelfLife&&<p><strong>Shelf life</strong><span>{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}</span></p>}
|
||||
{JSON.parse(additional.notes_json??"[]").length>0&&<ul>{JSON.parse(additional.notes_json).map((note:string)=><li>{note}</li>)}</ul>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{!editing&&<script is:inline>document.querySelectorAll('[data-recipe-tab]').forEach(button=>button.addEventListener('click',()=>{document.querySelectorAll('[data-recipe-tab]').forEach(x=>x.classList.remove('active'));document.querySelectorAll('[data-recipe-panel]').forEach(x=>x.classList.remove('active'));button.classList.add('active');document.querySelector(`[data-recipe-panel="${button.dataset.recipeTab}"]`)?.classList.add('active');}));const recipeHash=location.hash.slice(1);if(recipeHash)document.querySelector(`[data-recipe-tab="${recipeHash}"]`)?.click();</script>}
|
||||
<script is:inline>
|
||||
function switchRecipeTab(tabName) {
|
||||
document.querySelectorAll('[data-recipe-tab]').forEach(x=>x.classList.toggle('active', x.dataset.recipeTab === tabName));
|
||||
document.querySelectorAll('[data-recipe-panel]').forEach(x=>x.classList.toggle('active', x.dataset.recipePanel === tabName));
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
}
|
||||
document.querySelectorAll('[data-recipe-tab]').forEach(button=>button.addEventListener('click',()=>{
|
||||
switchRecipeTab(button.dataset.recipeTab);
|
||||
}));
|
||||
const recipeHash=location.hash.slice(1);
|
||||
if(recipeHash) {
|
||||
const targetTab = document.querySelector(`[data-recipe-tab="${recipeHash}"]`);
|
||||
if (targetTab) {
|
||||
targetTab.click();
|
||||
}
|
||||
} else if (window.innerWidth <= 900) {
|
||||
switchRecipeTab('formula');
|
||||
}
|
||||
</script>
|
||||
{editing&&<script is:inline>
|
||||
let recipeDirty=false;
|
||||
const setRecipeDirty=(value=true)=>{recipeDirty=value;document.querySelector('#recipe-done')?.classList.toggle('dirty',value)};
|
||||
const setupTagEditor=()=>{const source=document.querySelector('#recipe-additional-form input[name="tags"]');if(!source||source.dataset.enhanced)return;source.dataset.enhanced='1';source.type='hidden';const editor=document.createElement('div'),chips=document.createElement('div'),entry=document.createElement('input');editor.className='tag-chip-editor';chips.className='tag-chip-list';entry.className='tag-chip-entry';entry.placeholder='Tag Name';let tags=source.value.split(',').map(value=>value.trim()).filter(Boolean);const render=()=>{chips.replaceChildren(...tags.map(tag=>{const chip=document.createElement('span'),label=document.createElement('b'),remove=document.createElement('button');label.textContent=tag;remove.type='button';remove.textContent='×';remove.ariaLabel=`Remove ${tag}`;remove.onclick=()=>{tags=tags.filter(value=>value!==tag);source.value=tags.join(', ');render();setRecipeDirty()};chip.append(label,remove);return chip}));source.value=tags.join(', ')};const add=()=>{const tag=entry.value.trim().replace(/^#+/,'');if(tag&&!tags.some(value=>value.toLowerCase()===tag.toLowerCase())){tags.push(tag);setRecipeDirty()}entry.value='';render()};entry.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===','){event.preventDefault();add()}else if(event.key==='Backspace'&&!entry.value&&tags.length){tags.pop();render();setRecipeDirty()}});entry.addEventListener('blur',add);source.after(editor);editor.append(chips,entry);render()};
|
||||
const setupRecipeEditor=()=>{const method=document.querySelector('.method-editor');if(!method||method.dataset.tabsReady)return;method.dataset.tabsReady='1';const coverSlot=method.querySelector('#recipe-cover-slot'),additionalSlot=method.querySelector('#recipe-additional-slot'),panelSlot=method.querySelector('#recipe-tab-panel-slot');const prepChildren=[method.querySelector(':scope > h2'),method.querySelector(':scope > ol'),method.querySelector(':scope > button')].filter(Boolean);const panels=[...document.querySelectorAll('[data-edit-recipe-panel]')];const additional=document.querySelector('#additional');const cover=additional?.querySelector('.cover-media-field');if(cover&&coverSlot)coverSlot.append(cover);panels.forEach(panel=>{panel.hidden=true;panelSlot?.append(panel)});if(additional&&additionalSlot)additionalSlot.append(additional);document.querySelectorAll('[data-edit-recipe-tab]').forEach(button=>button.addEventListener('click',()=>{const selected=button.dataset.editRecipeTab;document.querySelectorAll('[data-edit-recipe-tab]').forEach(tab=>tab.classList.toggle('active',tab===button));prepChildren.forEach(child=>child.hidden=selected!=='method');if(coverSlot)coverSlot.hidden=selected!=='method';if(additionalSlot)additionalSlot.hidden=selected!=='method';if(panelSlot)panelSlot.hidden=selected==='method';panels.forEach(panel=>panel.hidden=panel.dataset.editRecipePanel!==selected)}));};
|
||||
document.addEventListener('recipe:editor-ready',setupRecipeEditor);
|
||||
document.addEventListener('recipe:dirty',event=>setRecipeDirty(Boolean(event.detail)));
|
||||
document.querySelector('#recipe-details-form')?.addEventListener('input',()=>setRecipeDirty());
|
||||
document.querySelector('#recipe-additional-form')?.addEventListener('input',()=>setRecipeDirty());
|
||||
document.querySelector('#recipe-details-form')?.addEventListener('submit',event=>event.preventDefault());
|
||||
window.addEventListener('beforeunload',event=>{if(recipeDirty)event.preventDefault()});
|
||||
setTimeout(()=>{setupRecipeEditor();setupTagEditor()},0);
|
||||
setTimeout(()=>setupTagEditor(),0);
|
||||
document.querySelector('#recipe-done')?.addEventListener('click',async(event)=>{const button=event.currentTarget;button.disabled=true;button.textContent='Saving…';const saved=await new Promise(resolve=>document.dispatchEvent(new CustomEvent('recipe:save-structure',{detail:{complete:resolve}})));if(saved){recipeDirty=false;location.href=button.dataset.viewUrl}else{button.disabled=false;button.textContent='✓ Done';}});
|
||||
</script>}
|
||||
</BaseLayout>
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import { openDatabase } from "../../../../lib/database";
|
||||
import { exportDatabase } from "../../../../lib/backup";
|
||||
import { readOnlyMode } from "../../../../lib/runtime";
|
||||
|
||||
const database = openDatabase({ readOnly: true });
|
||||
if (!database) return Astro.redirect("/app/", 303);
|
||||
|
||||
let summary = {
|
||||
recipes_count: 0,
|
||||
ingredients_count: 0,
|
||||
purchase_items_count: 0,
|
||||
collections_count: 0,
|
||||
inventory_counts_count: 0,
|
||||
inventory_locations_count: 0,
|
||||
total_records_count: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
const bundle = exportDatabase(database);
|
||||
summary = bundle.summary;
|
||||
} catch (err) {
|
||||
console.error("Failed to compute database summary:", err);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title="Data Management & Backups" immersive>
|
||||
<div class="settings-page">
|
||||
<DetailUtility section="Data Management" sectionHref="/app/settings/" />
|
||||
|
||||
<main class="settings-workspace">
|
||||
<header class="settings-header">
|
||||
<div class="settings-header-left">
|
||||
<h1>Data Management</h1>
|
||||
<p class="settings-subtitle">
|
||||
Export complete database archives or restore backups across all 25 tables with full referential integrity.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="settings-grid">
|
||||
<!-- Database Overview Card -->
|
||||
<section class="settings-card db-status-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon status-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 4.02 2 6.5v11C2 19.98 6.48 22 12 22s10-2.02 10-4.5v-11C22 4.02 17.52 2 12 2zm0 2c4.42 0 8 1.45 8 2.5S16.42 9 12 9 4 7.55 4 6.5 7.58 4 12 4zm8 13.5c0 1.05-3.58 2.5-8 2.5s-8-1.45-8-2.5V14.2c1.94 1.14 4.8 1.8 8 1.8s6.06-.66 8-1.8v3.3zm0-5.5c0 1.05-3.58 2.5-8 2.5s-8-1.45-8-2.5V8.7c1.94 1.14 4.8 1.8 8 1.8s6.06-.66 8-1.8V12z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Active Database Status</h2>
|
||||
<span class="card-caption">SQLite local storage ({summary.total_records_count} total entities)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-metrics-grid">
|
||||
<div class="stat-pill">
|
||||
<span class="stat-label">Recipes</span>
|
||||
<span class="stat-value">{summary.recipes_count}</span>
|
||||
</div>
|
||||
<div class="stat-pill">
|
||||
<span class="stat-label">Ingredients</span>
|
||||
<span class="stat-value">{summary.ingredients_count}</span>
|
||||
</div>
|
||||
<div class="stat-pill">
|
||||
<span class="stat-label">Purchase Items</span>
|
||||
<span class="stat-value">{summary.purchase_items_count}</span>
|
||||
</div>
|
||||
<div class="stat-pill">
|
||||
<span class="stat-label">Recipe Books</span>
|
||||
<span class="stat-value">{summary.collections_count}</span>
|
||||
</div>
|
||||
<div class="stat-pill">
|
||||
<span class="stat-label">Inventory Counts</span>
|
||||
<span class="stat-value">{summary.inventory_counts_count}</span>
|
||||
</div>
|
||||
<div class="stat-pill">
|
||||
<span class="stat-label">Storage Locations</span>
|
||||
<span class="stat-value">{summary.inventory_locations_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Export Backup Card -->
|
||||
<section class="settings-card export-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon export-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
|
||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Export Database Backup</h2>
|
||||
<span class="card-caption">Download full-fidelity JSON archive</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="card-description">
|
||||
Generates a portable backup bundle containing 100% of your formulations, ingredients, purchasing catalogs, cost histories, and inventory count sessions.
|
||||
</p>
|
||||
|
||||
<div class="card-actions">
|
||||
<a href="/api/app/backup/export" download class="btn-primary export-btn" id="exportBackupBtn">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||
</svg>
|
||||
<span>Export Full Backup (.json)</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Import & Restore Card -->
|
||||
<section class="settings-card import-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon import-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
|
||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Import & Restore Backup</h2>
|
||||
<span class="card-caption">Restore database from backup JSON</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{readOnlyMode ? (
|
||||
<div class="notice readonly-notice">
|
||||
Database restoration is disabled in read-only demonstration mode.
|
||||
</div>
|
||||
) : (
|
||||
<div class="import-interactive-zone">
|
||||
<div class="drop-zone" id="backupDropZone">
|
||||
<input type="file" id="backupFileInput" accept=".json" class="file-input-hidden" />
|
||||
<svg viewBox="0 0 24 24" width="36" height="36" fill="currentColor" class="drop-icon">
|
||||
<path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z"/>
|
||||
</svg>
|
||||
<p class="drop-title">Select or drag a Formulation backup (.json) file</p>
|
||||
<button type="button" class="btn-secondary" id="browseFileBtn">Browse File</button>
|
||||
</div>
|
||||
|
||||
<!-- Preview Dialog (Hidden until file selected) -->
|
||||
<div class="import-preview-box" id="importPreviewBox" style="display: none;">
|
||||
<div class="preview-header">
|
||||
<h3>Backup File Validation</h3>
|
||||
<span class="badge-valid" id="validationBadge">Valid</span>
|
||||
</div>
|
||||
|
||||
<div class="preview-metrics" id="previewMetrics">
|
||||
<!-- Injected via JavaScript -->
|
||||
</div>
|
||||
|
||||
<div class="mode-selection-group">
|
||||
<label class="mode-radio">
|
||||
<input type="radio" name="importMode" value="replace" checked />
|
||||
<div class="mode-text">
|
||||
<strong>Clean Restore (Replace)</strong>
|
||||
<span>Safely replaces all database records with this backup snapshot inside an atomic transaction.</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="mode-radio">
|
||||
<input type="radio" name="importMode" value="merge" />
|
||||
<div class="mode-text">
|
||||
<strong>Smart Merge (Upsert)</strong>
|
||||
<span>Updates existing items and adds new items without deleting existing data.</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="preview-actions">
|
||||
<button type="button" class="btn-primary" id="confirmImportBtn">
|
||||
Restore Database Now
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" id="cancelImportBtn">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Banner -->
|
||||
<div class="import-status-banner" id="importStatusBanner" style="display: none;"></div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
const fileInput = document.getElementById("backupFileInput") as HTMLInputElement | null;
|
||||
const browseBtn = document.getElementById("browseFileBtn") as HTMLButtonElement | null;
|
||||
const dropZone = document.getElementById("backupDropZone") as HTMLDivElement | null;
|
||||
const previewBox = document.getElementById("importPreviewBox") as HTMLDivElement | null;
|
||||
const previewMetrics = document.getElementById("previewMetrics") as HTMLDivElement | null;
|
||||
const validationBadge = document.getElementById("validationBadge") as HTMLSpanElement | null;
|
||||
const confirmBtn = document.getElementById("confirmImportBtn") as HTMLButtonElement | null;
|
||||
const cancelBtn = document.getElementById("cancelImportBtn") as HTMLButtonElement | null;
|
||||
const statusBanner = document.getElementById("importStatusBanner") as HTMLDivElement | null;
|
||||
|
||||
let loadedBundle: any = null;
|
||||
|
||||
if (browseBtn && fileInput) {
|
||||
browseBtn.addEventListener("click", () => fileInput.click());
|
||||
}
|
||||
|
||||
if (dropZone && fileInput) {
|
||||
dropZone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add("dragover");
|
||||
});
|
||||
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("dragover"));
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("dragover");
|
||||
if (e.dataTransfer?.files.length) {
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
});
|
||||
fileInput.addEventListener("change", () => {
|
||||
if (fileInput.files?.length) {
|
||||
handleFile(fileInput.files[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener("click", resetImport);
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (!file.name.endsWith(".json")) {
|
||||
showStatus("Please select a valid .json file.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showStatus("Validating backup file...", "info");
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
const res = await fetch("/api/app/backup/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(parsed),
|
||||
});
|
||||
|
||||
const validation = await res.json();
|
||||
|
||||
if (!validation.valid) {
|
||||
showStatus(`Invalid backup file: ${validation.errors.join("; ")}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
loadedBundle = parsed;
|
||||
hideStatus();
|
||||
|
||||
if (previewBox && previewMetrics && validationBadge) {
|
||||
dropZone!.style.display = "none";
|
||||
previewBox.style.display = "block";
|
||||
|
||||
const s = validation.summary;
|
||||
previewMetrics.innerHTML = `
|
||||
<div class="metric-row"><strong>Exported At:</strong> <span>${new Date(parsed.exported_at).toLocaleString()}</span></div>
|
||||
<div class="metric-row"><strong>Format Version:</strong> <span>${parsed.format_version}</span></div>
|
||||
<div class="metric-row"><strong>Recipes:</strong> <span>${s.recipes_count}</span></div>
|
||||
<div class="metric-row"><strong>Ingredients:</strong> <span>${s.ingredients_count}</span></div>
|
||||
<div class="metric-row"><strong>Purchase Items:</strong> <span>${s.purchase_items_count}</span></div>
|
||||
<div class="metric-row"><strong>Inventory Counts:</strong> <span>${s.inventory_counts_count}</span></div>
|
||||
<div class="metric-row"><strong>Total Records:</strong> <span>${s.total_records_count}</span></div>
|
||||
`;
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus(`Failed to read file: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmBtn) {
|
||||
confirmBtn.addEventListener("click", async () => {
|
||||
if (!loadedBundle) return;
|
||||
|
||||
const modeRadio = document.querySelector('input[name="importMode"]:checked') as HTMLInputElement | null;
|
||||
const mode = modeRadio?.value ?? "replace";
|
||||
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = "Restoring Database...";
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/app/backup/import?mode=${mode}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(loadedBundle),
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (!res.ok || !result.success) {
|
||||
showStatus(`Import failed: ${result.error || "Unknown error"}`, "error");
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = "Restore Database Now";
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus(result.message + " Reloading workspace...", "success");
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
showStatus(`Import error: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = "Restore Database Now";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetImport() {
|
||||
loadedBundle = null;
|
||||
if (fileInput) fileInput.value = "";
|
||||
if (previewBox) previewBox.style.display = "none";
|
||||
if (dropZone) dropZone.style.display = "flex";
|
||||
hideStatus();
|
||||
}
|
||||
|
||||
function showStatus(message: string, type: "info" | "error" | "success") {
|
||||
if (!statusBanner) return;
|
||||
statusBanner.className = `import-status-banner status-${type}`;
|
||||
statusBanner.textContent = message;
|
||||
statusBanner.style.display = "block";
|
||||
}
|
||||
|
||||
function hideStatus() {
|
||||
if (statusBanner) statusBanner.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.settings-page {
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-workspace {
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 64px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.settings-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #050841;
|
||||
margin: 0 0 6px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.settings-subtitle {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.export-icon {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.import-icon {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #050841;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.card-caption {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.stats-metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.stat-pill {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #050841;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 10px 18px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 9px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #cbd5e1;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 36px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: #fafafa;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.drop-zone.dragover {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.drop-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.import-preview-box {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.preview-header h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #050841;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.badge-valid {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.preview-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
:global(.metric-row) {
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
:global(.metric-row strong) {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.mode-selection-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mode-radio {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mode-radio input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.mode-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mode-text strong {
|
||||
font-size: 14px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.mode-text span {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.import-status-banner {
|
||||
margin-top: 14px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.readonly-notice {
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
border: 1px solid #fef3c7;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,57 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
import fs from "node:fs"; import path from "node:path"; import YAML from "yaml";
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro"; import PurchasingReview from "../../../components/PurchasingReview";
|
||||
import { ingredients } from "../../../lib/data";
|
||||
const file=path.resolve(process.cwd(),"generated/receipt-product-candidates.yaml"); const data=fs.existsSync(file)?YAML.parse(fs.readFileSync(file,"utf8")):null;
|
||||
const ingredientOptions=[...ingredients.values()].map(({id,name})=>({id,name})).sort((a,b)=>a.name.localeCompare(b.name));
|
||||
export const prerender = false;
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import DetailUtility from "../../../components/DetailUtility.astro";
|
||||
import PurchasingReview from "../../../components/PurchasingReview";
|
||||
import { openDatabase } from "../../../lib/database";
|
||||
import { titleCase } from "../../../lib/format";
|
||||
|
||||
const file = path.resolve(process.cwd(), "generated/receipt-product-candidates.yaml");
|
||||
const data = fs.existsSync(file) ? YAML.parse(fs.readFileSync(file, "utf8")) : null;
|
||||
|
||||
const database = openDatabase();
|
||||
const ingredientOptions = database
|
||||
? (database.prepare("SELECT id, name FROM ingredients WHERE status = 'active' ORDER BY name").all() as Array<{ id: string; name: string }>).map((i) => ({ id: i.id, name: titleCase(i.name) }))
|
||||
: [];
|
||||
if (database) database.close();
|
||||
---
|
||||
<BaseLayout title="Purchasing review"><section class="shell page-heading"><p class="eyebrow">Local data tool</p><h1>Receipt product review</h1><p>Link actual Walmart and Sam's Club products to canonical ingredients.</p></section><section class="shell section-block">{data?<PurchasingReview client:load products={data.products} ingredients={ingredientOptions}/>:<div class="notice">Run <code>scripts/receipt-products propose</code>, then rebuild.</div>}</section></BaseLayout>
|
||||
|
||||
<BaseLayout title="Purchasing review" immersive>
|
||||
<div class="purchasing-review-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="purchasing-review-workspace">
|
||||
<header class="purchasing-review-header">
|
||||
<nav class="purchasing-breadcrumbs">
|
||||
<a href="/app/">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>All items</span>
|
||||
</a>
|
||||
</nav>
|
||||
<h1>Receipt product review</h1>
|
||||
<p class="purchasing-subtitle">Link Walmart and Sam's Club purchase products to canonical formulation ingredients.</p>
|
||||
</header>
|
||||
|
||||
{data ? (
|
||||
<PurchasingReview client:load products={data.products} ingredients={ingredientOptions} />
|
||||
) : (
|
||||
<div class="purchasing-notice-card">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
<div>
|
||||
<strong>No candidates generated yet</strong>
|
||||
<p>Run <code>scripts/receipt-products propose</code> to parse receipt files and extract product candidates.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -2,27 +2,96 @@
|
||||
interface Props { section?: string; sectionHref?: string }
|
||||
import { readOnlyMode } from "../lib/runtime";
|
||||
const { section, sectionHref } = Astro.props;
|
||||
const inferred = Astro.url.pathname.includes("/ingredients/")
|
||||
? { label:"Ingredients", href:"/app/?type=ingredient" }
|
||||
: Astro.url.pathname.includes("/recipe-books/")
|
||||
? { label:"Recipe Books", href:"/app/?type=book" }
|
||||
: Astro.url.pathname.includes("/recipes/")
|
||||
? { label:"Recipes", href:"/app/?type=recipe" }
|
||||
: { label:undefined, href:"/app/" };
|
||||
const resolvedSection=section??inferred.label;
|
||||
const resolvedHref=sectionHref??inferred.href;
|
||||
const pathname = Astro.url.pathname.replace(/\/+$/, "") + "/";
|
||||
|
||||
let inferredLabel: string | undefined;
|
||||
let inferredHref = "/app/";
|
||||
|
||||
if (pathname.startsWith("/app/inventory/")) {
|
||||
if (pathname === "/app/inventory/") {
|
||||
inferredLabel = "Inventory";
|
||||
inferredHref = "/app/";
|
||||
} else {
|
||||
inferredLabel = "Inventory";
|
||||
inferredHref = "/app/inventory/";
|
||||
}
|
||||
} else if (pathname.startsWith("/app/ingredients/")) {
|
||||
inferredLabel = "Ingredients";
|
||||
inferredHref = "/app/?type=ingredient";
|
||||
} else if (pathname.startsWith("/app/recipe-books/")) {
|
||||
inferredLabel = "Recipe Books";
|
||||
inferredHref = "/app/?type=book";
|
||||
} else if (pathname.startsWith("/app/recipes/")) {
|
||||
inferredLabel = "Recipes";
|
||||
inferredHref = "/app/?type=recipe";
|
||||
} else if (pathname.startsWith("/app/archive")) {
|
||||
inferredLabel = "Archive";
|
||||
inferredHref = "/app/";
|
||||
} else if (pathname.startsWith("/app/settings")) {
|
||||
inferredLabel = "Data Management";
|
||||
inferredHref = "/app/settings/";
|
||||
} else if (pathname.startsWith("/tools/purchasing-review")) {
|
||||
inferredLabel = "Purchasing Review";
|
||||
inferredHref = "/app/";
|
||||
}
|
||||
|
||||
const resolvedSection = section ?? inferredLabel;
|
||||
const resolvedHref = sectionHref ?? inferredHref;
|
||||
---
|
||||
<nav class="detail-utility" aria-label="Entity navigation">
|
||||
<div class="detail-context">
|
||||
<a class="detail-back" href={resolvedHref} aria-label={`Back to ${resolvedSection??"home"}`}>‹</a>
|
||||
<a class="detail-back" href={resolvedHref} aria-label={`Back to ${resolvedSection??"home"}`} data-back-link>‹</a>
|
||||
<a class="detail-avatar" href="/app/" aria-label="Recipe Book home">RB</a>
|
||||
<ol class="detail-breadcrumbs">
|
||||
<li><a href="/app/">Home</a></li>
|
||||
{resolvedSection&&<li aria-current="page"><a href={resolvedHref}>{resolvedSection}</a></li>}
|
||||
</ol>
|
||||
<slot />
|
||||
</div>
|
||||
<div class="detail-tools">
|
||||
<form action="/app/" role="search"><input name="q" type="search" placeholder="Search" aria-label="Search all items"/></form>
|
||||
{!readOnlyMode&&<details class="detail-new-menu"><summary><span class="new-trigger-plus" aria-hidden="true">+</span><span>New</span></summary><div><a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true">▦</span><strong>Recipe</strong></a><a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true">▣</span><strong>Recipe book</strong></a></div></details>}
|
||||
<form action="/app/" role="search" class="detail-search-form">
|
||||
<span class="search-input-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
</span>
|
||||
<input name="q" type="text" placeholder="Search" aria-label="Search all items" class="search-field" autocomplete="off"/>
|
||||
<button type="button" class="search-clear-btn" aria-label="Clear search" title="Clear search">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{!readOnlyMode&&<details class="detail-new-menu">
|
||||
<summary><span class="new-trigger-plus" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg></span><span>New</span></summary>
|
||||
<div>
|
||||
<a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z" style="transform: scale(1.1, 1.1) translate(4px, 3.5px);"/></svg></span><strong>Recipe</strong></a>
|
||||
<a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765C2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z" style="transform: scale(1.1, 1.1) translate(3.5px, 2.5px);"/></svg></span><strong>Recipe book</strong></a>
|
||||
</div>
|
||||
</details>}
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
document.querySelectorAll('.detail-search-form').forEach((formEl) => {
|
||||
const form = formEl as HTMLFormElement;
|
||||
const input = form.querySelector('.search-field') as HTMLInputElement | null;
|
||||
const clearBtn = form.querySelector('.search-clear-btn') as HTMLButtonElement | null;
|
||||
if (!input || !clearBtn) return;
|
||||
const sync = () => {
|
||||
if (input.value.trim().length > 0) form.classList.add('has-value');
|
||||
else form.classList.remove('has-value');
|
||||
};
|
||||
input.addEventListener('input', sync);
|
||||
clearBtn.addEventListener('click', () => {
|
||||
input.value = '';
|
||||
sync();
|
||||
input.focus();
|
||||
});
|
||||
sync();
|
||||
});
|
||||
|
||||
document.querySelectorAll('.detail-back[data-back-link]').forEach((link) => {
|
||||
link.addEventListener('click', (e) => {
|
||||
if (window.history.length > 1 && document.referrer && new URL(document.referrer, window.location.origin).origin === window.location.origin) {
|
||||
e.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { useRef,useState } from "preact/hooks";
|
||||
|
||||
export type DirectoryRow = {
|
||||
id:string; name:string; href?:string; kind:"recipe"|"ingredient"|"book"|"purchase"; icon:string;
|
||||
id:string; name:string; href?:string; kind:"recipe"|"ingredient"|"book"|"purchase"; detail?:string;
|
||||
};
|
||||
type Props={ rows:DirectoryRow[]; entityType:DirectoryRow["kind"]; emptyMessage:string; readOnly?:boolean };
|
||||
const TYPE_ICONS:Record<DirectoryRow["kind"],string> = {
|
||||
recipe:"M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z",
|
||||
ingredient:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z",
|
||||
book:"M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765 2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z",
|
||||
purchase:"M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z"
|
||||
};
|
||||
const TYPE_ICON_TRANSFORMS:Record<DirectoryRow["kind"],string|undefined> = {
|
||||
recipe:"scale(1.1, 1.1) translate(4px, 3.5px)",
|
||||
ingredient:"scale(1.3, 1.3) translate(2.5px, 1px)",
|
||||
book:"scale(1.1, 1.1) translate(3.5px, 2.5px)",
|
||||
purchase:undefined
|
||||
};
|
||||
type Props={ rows:DirectoryRow[]; entityType?:DirectoryRow["kind"]; emptyMessage:string; readOnly?:boolean };
|
||||
|
||||
export default function EntityDirectory({rows,entityType,emptyMessage,readOnly=false}:Props) {
|
||||
const [selected,setSelected]=useState<string[]>([]),[deleting,setDeleting]=useState(false),[error,setError]=useState("");
|
||||
@@ -12,33 +24,59 @@ export default function EntityDirectory({rows,entityType,emptyMessage,readOnly=f
|
||||
const allSelected=rows.length>0&&selected.length===rows.length;
|
||||
const toggle=(id:string)=>setSelected(current=>current.includes(id)?current.filter(value=>value!==id):[...current,id]);
|
||||
const requestDelete=(ids:string[])=>{if(!ids.length)return;setPendingDelete(ids);dialog.current?.showModal();};
|
||||
const kindById=new Map(rows.map(row=>[row.id,row.kind]));
|
||||
const remove=async()=>{
|
||||
const ids=pendingDelete;
|
||||
if(!ids.length)return;
|
||||
setDeleting(true);setError("");
|
||||
const response=await fetch("/api/app/entities/delete",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({type:entityType,ids})});
|
||||
const result=await response.json();
|
||||
if(!response.ok){setError(result.error??"Unable to delete selection.");setDeleting(false);dialog.current?.close();return;}
|
||||
const groups=new Map<DirectoryRow["kind"],string[]>();
|
||||
for(const id of ids){const kind=kindById.get(id)??entityType??"recipe";if(!groups.has(kind))groups.set(kind,[]);groups.get(kind)!.push(id);}
|
||||
try{
|
||||
for(const [kind,groupIds] of groups){
|
||||
const response=await fetch("/api/app/entities/delete",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({type:kind,ids:groupIds})});
|
||||
const result=await response.json();
|
||||
if(!response.ok){setError(result.error??"Unable to delete selection.");dialog.current?.close();return;}
|
||||
}
|
||||
} finally { setDeleting(false); }
|
||||
location.reload();
|
||||
};
|
||||
return <section class={`entity-directory-table${readOnly?" read-only":""}`}>
|
||||
{!readOnly&&<div class="entity-directory-toolbar">
|
||||
<input aria-label={`Select all ${entityType} items`} type="checkbox" checked={allSelected} ref={input=>{if(input)input.indeterminate=selected.length>0&&!allSelected;}} onChange={()=>setSelected(allSelected?[]:rows.map(row=>row.id))}/>
|
||||
<strong>{selected.length?`${selected.length} selected`:""}</strong>
|
||||
{selected.length>0&&<><button class="bulk-delete" type="button" disabled={deleting} onClick={()=>requestDelete(selected)}>⌫ Delete</button><button type="button" onClick={()=>setSelected([])}>Clear</button></>}
|
||||
{!readOnly&&<div class={`entity-directory-toolbar entity-directory-head${selected.length>0?" has-selection":""}`}>
|
||||
<input aria-label={`Select all ${entityType??"items"}`} type="checkbox" checked={allSelected} ref={input=>{if(input)input.indeterminate=selected.length>0&&!allSelected;}} onChange={()=>setSelected(allSelected?[]:rows.map(row=>row.id))}/>
|
||||
{selected.length>0 ? (
|
||||
<div class="entity-directory-selection-bar">
|
||||
<strong class="selection-count">{selected.length} Selected</strong>
|
||||
<div class="entity-directory-toolbar-actions">
|
||||
<button class="bulk-action-btn bulk-delete" type="button" disabled={deleting} onClick={()=>requestDelete(selected)} title="Delete selected" aria-label="Delete selected">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
<button class="bulk-action-btn bulk-clear" type="button" onClick={()=>setSelected([])} title="Clear selection" aria-label="Clear selection">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span>Type</span>
|
||||
<span>Name</span>
|
||||
<div class="entity-directory-toolbar-actions"></div>
|
||||
</>
|
||||
)}
|
||||
</div>}
|
||||
{error&&<p class="directory-error">{error}</p>}
|
||||
<div>{rows.map(row=><div class={`entity-directory-row${selected.includes(row.id)?" selected":""}`}>
|
||||
{!readOnly&&<input aria-label={`Select ${row.name}`} type="checkbox" checked={selected.includes(row.id)} onChange={()=>toggle(row.id)}/>}
|
||||
<span class={`workspace-pill-icon ${row.kind}`}>{row.icon}</span>
|
||||
<span class="entity-directory-name">{row.href?<a href={row.href}><strong>{row.name}</strong></a>:<strong>{row.name}</strong>}</span>
|
||||
{!readOnly&&<details class="entity-row-actions"><summary aria-label={`Actions for ${row.name}`}>⋮</summary><div><button type="button" onClick={()=>requestDelete([row.id])}>Delete</button></div></details>}
|
||||
<span class={`workspace-pill-icon ${row.kind}`}><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d={TYPE_ICONS[row.kind]} style={TYPE_ICON_TRANSFORMS[row.kind]?{transform:TYPE_ICON_TRANSFORMS[row.kind]}:undefined}/></svg></span>
|
||||
<span class="entity-directory-name">{row.href?<a href={row.href}><strong>{row.name}</strong>{row.detail&&<small>{row.detail}</small>}</a>:<><strong>{row.name}</strong>{row.detail&&<small>{row.detail}</small>}</>}</span>
|
||||
{!readOnly&&<details class="entity-row-actions"><summary aria-label={`Actions for ${row.name}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg></summary><div><button type="button" onClick={()=>requestDelete([row.id])}>Delete</button></div></details>}
|
||||
</div>)}</div>
|
||||
{rows.length===0&&<div class="empty-state">{emptyMessage}</div>}
|
||||
{!readOnly&&<dialog class="delete-confirmation" ref={dialog} onClose={()=>{if(!deleting)setPendingDelete([]);}}>
|
||||
<form method="dialog"><button class="dialog-close" aria-label="Close">×</button></form>
|
||||
<h2>Delete {pendingDelete.length===1?"item":`${pendingDelete.length} items`}?</h2>
|
||||
<p>This permanently removes the selected {pendingDelete.length===1?entityType:`${entityType} items`}. This action cannot be undone.</p>
|
||||
<p>This permanently removes the selected {pendingDelete.length===1?(entityType??"item"):`${pendingDelete.length} ${entityType??"items"}`}. This action cannot be undone.</p>
|
||||
<div><form method="dialog"><button disabled={deleting}>Cancel</button></form><button class="confirm-delete" type="button" disabled={deleting} onClick={remove}>{deleting?"Deleting…":"Delete"}</button></div>
|
||||
</dialog>}
|
||||
</section>;
|
||||
|
||||
@@ -1,15 +1,292 @@
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
type Candidate={ingredient_id:string;name:string;score:number};
|
||||
type Product={supplier_id:string;supplier_sku:string;name:string;url?:string;package?:{quantity:number;unit_id:string};prices:Array<{amount:number;effective_at:string}>;ingredient_candidates:Candidate[]};
|
||||
type Props={products:Product[];ingredients:Array<{id:string;name:string}>}; type Decisions=Record<string,string|null>;
|
||||
const STORAGE_KEY="recipe-book-purchasing-decisions-v1";
|
||||
export default function PurchasingReview({products,ingredients}:Props){
|
||||
const [decisions,setDecisions]=useState<Decisions>({}); const [query,setQuery]=useState(""); const [unresolved,setUnresolved]=useState(true);
|
||||
useEffect(()=>{try{setDecisions(JSON.parse(localStorage.getItem(STORAGE_KEY)??"{}"))}catch{}},[]);
|
||||
const choose=(key:string,value:string|null)=>{const next={...decisions,[key]:value};setDecisions(next);localStorage.setItem(STORAGE_KEY,JSON.stringify(next))};
|
||||
const visible=useMemo(()=>products.filter(p=>{const key=`${p.supplier_id}:${p.supplier_sku}`;return p.name.toLowerCase().includes(query.toLowerCase())&&(!unresolved||!(key in decisions))}),[products,query,unresolved,decisions]);
|
||||
const download=()=>{const url=URL.createObjectURL(new Blob([JSON.stringify({schema_version:1,generated_at:new Date().toISOString(),decisions},null,2)],{type:"application/json"}));const anchor=document.createElement("a");anchor.href=url;anchor.download="purchasing-decisions.json";anchor.click();URL.revokeObjectURL(url)};
|
||||
return <section><div class="review-toolbar"><div><strong>{Object.keys(decisions).length} / {products.length}</strong> reviewed<small>Products without explicit package sizes cannot be imported yet</small></div><input type="search" placeholder="Filter products" value={query} onInput={e=>setQuery(e.currentTarget.value)}/><label><input type="checkbox" checked={unresolved} onChange={e=>setUnresolved(e.currentTarget.checked)}/> Unresolved only</label><button onClick={download}>Export decisions</button></div>
|
||||
{visible.map(product=>{const key=`${product.supplier_id}:${product.supplier_sku}`;return <fieldset class="candidate-card" key={key}><legend>{product.name}</legend><p class="product-meta">{product.supplier_id.replace("_"," ")} · SKU {product.supplier_sku} · {product.package?`${product.package.quantity} ${product.package.unit_id}`:"package unknown"} · latest ${product.prices.at(-1)?.amount.toFixed(2)}</p>{product.ingredient_candidates.map(candidate=><label class="candidate-choice" key={candidate.ingredient_id}><input type="radio" name={key} checked={decisions[key]===candidate.ingredient_id} onChange={()=>choose(key,candidate.ingredient_id)}/><span><strong>{candidate.name}</strong><small>{candidate.ingredient_id} · {Math.round(candidate.score*100)}% token match</small></span></label>)}<label class="other-choice"><span>Choose another canonical ingredient</span><select value={decisions[key]??""} onChange={e=>choose(key,e.currentTarget.value||null)}><option value="">Select…</option>{ingredients.map(i=><option value={i.id}>{i.name} · {i.id}</option>)}</select></label><label class="candidate-choice none"><input type="radio" name={key} checked={key in decisions&&decisions[key]===null} onChange={()=>choose(key,null)}/><span><strong>Not a recipe ingredient</strong><small>Do not import this product</small></span></label>{product.url&&<p><a href={product.url} target="_blank" rel="noreferrer">Inspect product ↗</a></p>}</fieldset>})}
|
||||
</section>;
|
||||
|
||||
type Candidate = {
|
||||
ingredient_id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
type Product = {
|
||||
supplier_id: string;
|
||||
supplier_sku: string;
|
||||
name: string;
|
||||
url?: string;
|
||||
package?: {
|
||||
quantity: number;
|
||||
unit_id: string;
|
||||
};
|
||||
prices: Array<{
|
||||
amount: number;
|
||||
effective_at: string;
|
||||
}>;
|
||||
ingredient_candidates: Candidate[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
products: Product[];
|
||||
ingredients: Array<{ id: string; name: string }>;
|
||||
};
|
||||
|
||||
type Decisions = Record<string, string | null>;
|
||||
|
||||
const STORAGE_KEY = "recipe-book-purchasing-decisions-v1";
|
||||
|
||||
export default function PurchasingReview({ products, ingredients }: Props) {
|
||||
const [decisions, setDecisions] = useState<Decisions>({});
|
||||
const [query, setQuery] = useState("");
|
||||
const [unresolved, setUnresolved] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setDecisions(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const choose = (key: string, value: string | null) => {
|
||||
const next = { ...decisions, [key]: value };
|
||||
setDecisions(next);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
};
|
||||
|
||||
const visible = useMemo(() => {
|
||||
return products.filter((p) => {
|
||||
const key = `${p.supplier_id}:${p.supplier_sku}`;
|
||||
const matchesQuery = p.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
p.supplier_sku.toLowerCase().includes(query.toLowerCase());
|
||||
const matchesResolution = !unresolved || !(key in decisions);
|
||||
return matchesQuery && matchesResolution;
|
||||
});
|
||||
}, [products, query, unresolved, decisions]);
|
||||
|
||||
const reviewedCount = Object.keys(decisions).length;
|
||||
const progressPercent = products.length > 0 ? Math.round((reviewedCount / products.length) * 100) : 0;
|
||||
|
||||
const download = () => {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob(
|
||||
[
|
||||
JSON.stringify(
|
||||
{
|
||||
schema_version: 1,
|
||||
generated_at: new Date().toISOString(),
|
||||
decisions,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
],
|
||||
{ type: "application/json" }
|
||||
)
|
||||
);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = "purchasing-decisions.json";
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const supplierLabel = (id: string) => {
|
||||
if (id === "walmart") return "Walmart";
|
||||
if (id === "sams_club") return "Sam's Club";
|
||||
return id.replace("_", " ");
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="purchasing-review-container">
|
||||
<div class="purchasing-review-toolbar">
|
||||
<div class="purchasing-review-stats">
|
||||
<div class="stats-counter">
|
||||
<strong class="stats-count">{reviewedCount} / {products.length}</strong>
|
||||
<span class="stats-label">reviewed ({progressPercent}%)</span>
|
||||
</div>
|
||||
<small class="stats-hint">Products without explicit package sizes cannot be imported automatically.</small>
|
||||
</div>
|
||||
|
||||
<div class="purchasing-review-actions">
|
||||
<div class="purchasing-search-box">
|
||||
<svg class="search-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search products or SKU..."
|
||||
value={query}
|
||||
onInput={(e) => setQuery(e.currentTarget.value)}
|
||||
class="search-input"
|
||||
/>
|
||||
{query && (
|
||||
<button class="search-clear-btn" onClick={() => setQuery("")} title="Clear search">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label class="purchasing-filter-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={unresolved}
|
||||
onChange={(e) => setUnresolved(e.currentTarget.checked)}
|
||||
/>
|
||||
<span class="toggle-track">
|
||||
<i class="toggle-thumb" />
|
||||
</span>
|
||||
<span class="toggle-label">Unresolved only</span>
|
||||
</label>
|
||||
|
||||
<button onClick={download} class="purchasing-export-btn" title="Download JSON decisions file">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
<span>Export decisions</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visible.length > 0 ? (
|
||||
<div class="purchasing-products-grid">
|
||||
{visible.map((product) => {
|
||||
const key = `${product.supplier_id}:${product.supplier_sku}`;
|
||||
const currentDecision = decisions[key];
|
||||
const isResolved = key in decisions;
|
||||
const latestPrice = product.prices.at(-1)?.amount;
|
||||
|
||||
return (
|
||||
<article class={`purchasing-card ${isResolved ? "resolved" : ""}`} key={key}>
|
||||
<header class="purchasing-card-header">
|
||||
<div class="purchasing-card-title-group">
|
||||
<div class="purchasing-badges">
|
||||
<span class={`supplier-badge ${product.supplier_id}`}>
|
||||
{supplierLabel(product.supplier_id)}
|
||||
</span>
|
||||
<span class="sku-badge">SKU #{product.supplier_sku}</span>
|
||||
{product.package && (
|
||||
<span class="package-badge">
|
||||
{product.package.quantity} {product.package.unit_id.replace("_", " ")}
|
||||
</span>
|
||||
)}
|
||||
{latestPrice != null && (
|
||||
<span class="price-badge">${latestPrice.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 class="purchasing-product-name">{product.name}</h3>
|
||||
</div>
|
||||
|
||||
{product.url && (
|
||||
<a
|
||||
href={product.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="purchasing-external-link"
|
||||
title="Inspect product in new tab"
|
||||
>
|
||||
<span>Inspect product</span>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div class="purchasing-candidates-section">
|
||||
<span class="candidates-heading">Select ingredient mapping:</span>
|
||||
|
||||
<div class="candidate-options-list">
|
||||
{product.ingredient_candidates.map((candidate) => {
|
||||
const isSelected = currentDecision === candidate.ingredient_id;
|
||||
const matchPercent = Math.round(candidate.score * 100);
|
||||
|
||||
return (
|
||||
<label
|
||||
class={`candidate-option-card ${isSelected ? "selected" : ""}`}
|
||||
key={candidate.ingredient_id}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={key}
|
||||
checked={isSelected}
|
||||
onChange={() => choose(key, candidate.ingredient_id)}
|
||||
class="candidate-radio"
|
||||
/>
|
||||
<span class="candidate-custom-radio">
|
||||
<i />
|
||||
</span>
|
||||
<div class="candidate-info">
|
||||
<strong class="candidate-name">{candidate.name}</strong>
|
||||
<span class="candidate-meta">{candidate.ingredient_id}</span>
|
||||
</div>
|
||||
<span class={`candidate-score-badge ${matchPercent >= 80 ? "high" : ""}`}>
|
||||
{matchPercent}% match
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
<div class="candidate-other-option">
|
||||
<label class="other-select-label">
|
||||
<span class="other-select-text">Or choose another ingredient:</span>
|
||||
<select
|
||||
value={currentDecision && !product.ingredient_candidates.some((c) => c.ingredient_id === currentDecision) ? currentDecision : ""}
|
||||
onChange={(e) => choose(key, e.currentTarget.value || null)}
|
||||
class="purchasing-select"
|
||||
>
|
||||
<option value="">Select ingredient…</option>
|
||||
{ingredients.map((i) => (
|
||||
<option value={i.id} key={i.id}>
|
||||
{i.name} ({i.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class={`candidate-option-card none ${isResolved && currentDecision === null ? "selected" : ""}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name={key}
|
||||
checked={isResolved && currentDecision === null}
|
||||
onChange={() => choose(key, null)}
|
||||
class="candidate-radio"
|
||||
/>
|
||||
<span class="candidate-custom-radio">
|
||||
<i />
|
||||
</span>
|
||||
<div class="candidate-info">
|
||||
<strong class="candidate-name">Not a recipe ingredient</strong>
|
||||
<span class="candidate-meta">Ignore and do not import this product</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div class="purchasing-empty-state">
|
||||
<div class="empty-icon-circle">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 14 14"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>No products to review</h3>
|
||||
<p>
|
||||
{unresolved
|
||||
? "All candidate products have been reviewed! Uncheck 'Unresolved only' to inspect past decisions."
|
||||
: "No products matched your search query."}
|
||||
</p>
|
||||
{query && (
|
||||
<button class="purchasing-reset-btn" onClick={() => setQuery("")}>
|
||||
Clear search filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import type { CalculatorComponent, CalculatorItem, Unit } from "../lib/types";
|
||||
import { convert } from "../lib/measurement";
|
||||
import type { NutritionResult } from "../lib/nutrition";
|
||||
import type { CostResult } from "../lib/costing";
|
||||
import type { CostLine, CostResult } from "../lib/costing";
|
||||
import NutritionPanel from "./NutritionPanel";
|
||||
import { number, roundForDisplay } from "../lib/format";
|
||||
|
||||
@@ -17,6 +17,7 @@ type Props = {
|
||||
cost: CostResult;
|
||||
servings?: number;
|
||||
showDerived?: boolean;
|
||||
showPercentControls?: boolean;
|
||||
yieldConversions?: CalculatorItem["measureConversions"];
|
||||
};
|
||||
|
||||
@@ -46,9 +47,9 @@ function convertItem(quantity: number, fromUnitId: string, toUnitId: string, ite
|
||||
throw new Error(`No reviewed equivalency from ${fromUnitId} to ${toUnitId}`);
|
||||
}
|
||||
|
||||
export default function RecipeCalculator({ components, units, basisUnitId, yieldQuantity, yieldUnitId, nutrition, cost, servings, showDerived = true, yieldConversions = [] }: Props) {
|
||||
export default function RecipeCalculator({ components, units, basisUnitId, yieldQuantity, yieldUnitId, nutrition, cost, servings, showDerived = true, showPercentControls = true, yieldConversions = [] }: Props) {
|
||||
const [factor, setFactor] = useState(1);
|
||||
const [calculatePercent,setCalculatePercent]=useState(true);
|
||||
const [calculatePercent,setCalculatePercent]=useState(showPercentControls);
|
||||
const [percentMode,setPercentMode]=useState<"standard"|"bakers">("standard");
|
||||
const [yieldDisplayUnitId, setYieldDisplayUnitId] = useState(yieldUnitId);
|
||||
const [lineUnits, setLineUnits] = useState<Record<string, string>>({});
|
||||
@@ -77,37 +78,62 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
|
||||
return (
|
||||
<section class="calculator" aria-labelledby="formula-heading">
|
||||
<div class="calculator-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Scalable formula</p>
|
||||
<h2 id="formula-heading">Ingredients</h2>
|
||||
</div>
|
||||
<label class="basis-input">
|
||||
<span>Batch multiplier</span>
|
||||
<span class="input-with-unit recipe-scale-control batch-size-control">
|
||||
<label class="basis-input batch-multiplier-field">
|
||||
<span class="scale-label">Batch:</span>
|
||||
<span class="recipe-scale-control batch-size-control">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
aria-label="Batch multiplier"
|
||||
value={roundForDisplay(factor)}
|
||||
onInput={(event) => setFactor(Number((event.currentTarget as HTMLInputElement).value))}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<label class="basis-input">
|
||||
<span>Finished yield</span>
|
||||
<span class="quantity-control recipe-scale-control"><input type="number" min="0" step="any" value={roundForDisplay(scaledYield)} onInput={(event) => changeYield(Number(event.currentTarget.value))}/><select aria-label="Yield unit" value={yieldDisplayUnitId} onChange={(event) => setYieldDisplayUnitId(event.currentTarget.value)}>{yieldUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}</select></span>
|
||||
<label class="basis-input finished-yield-field">
|
||||
<span class="scale-label">Yield:</span>
|
||||
<span class="recipe-scale-control quantity-control">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
aria-label="Finished yield quantity"
|
||||
value={roundForDisplay(scaledYield)}
|
||||
onInput={(event) => changeYield(Number((event.currentTarget as HTMLInputElement).value))}
|
||||
/>
|
||||
<select aria-label="Finished yield unit" value={yieldDisplayUnitId} onChange={(event) => setYieldDisplayUnitId((event.currentTarget as HTMLSelectElement).value)}>
|
||||
{yieldUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
<div class="calculator-percent-controls">{calculatePercent&&<span class="percent-mode"><button class={percentMode==="standard"?"active":""} onClick={()=>setPercentMode("standard")}>Standard %</button><button class={percentMode==="bakers"?"active":""} onClick={()=>setPercentMode("bakers")}>Baker's %</button></span>}<label class="calculate-toggle"><span>Calculate %</span><input type="checkbox" checked={calculatePercent} onChange={(event)=>setCalculatePercent(event.currentTarget.checked)}/><i></i></label></div>
|
||||
{showPercentControls && (
|
||||
<div class="calculator-percent-controls">
|
||||
{calculatePercent && (
|
||||
<span class="percent-mode">
|
||||
<button type="button" class={percentMode === "standard" ? "active" : ""} onClick={() => setPercentMode("standard")}>Standard %</button>
|
||||
<button type="button" class={percentMode === "bakers" ? "active" : ""} onClick={() => setPercentMode("bakers")}>Baker's %</button>
|
||||
</span>
|
||||
)}
|
||||
<label class="calculate-toggle">
|
||||
<span>Calculate %</span>
|
||||
<input type="checkbox" checked={calculatePercent} onChange={(event) => setCalculatePercent((event.currentTarget as HTMLInputElement).checked)} />
|
||||
<i></i>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p class="scale-relationship">1× produces {number(yieldQuantity)} {units[yieldUnitId]?.symbol??yieldUnitId} finished yield. Changing the multiplier, finished yield, or any ingredient amount scales the entire recipe.</p>
|
||||
|
||||
{calculatePercent&&percentMode==="bakers"&&baseItems.length>0&&<section class="bakers-base-summary"><strong>Base</strong><div>{baseItems.map(item=>{const displayUnitId=lineUnits[item.id]??item.amount.unit_id;const displayQuantity=convertItem(item.amount.quantity*validFactor,item.amount.unit_id,displayUnitId,item,units);return <p><span><input aria-label={`${item.label} base quantity`} type="number" min="0" step="any" value={roundForDisplay(displayQuantity)} onInput={(event)=>{const canonicalQuantity=convertItem(Number(event.currentTarget.value),displayUnitId,item.amount.unit_id,item,units);setFactor(item.amount.quantity>0?canonicalQuantity/item.amount.quantity:1)}}/><select aria-label={`${item.label} base unit`} value={displayUnitId} onChange={(event)=>setLineUnits((current)=>({...current,[item.id]:event.currentTarget.value}))}>{itemUnits(item,units).map(unit=><option value={unit.id}>{unit.symbol}</option>)}</select></span><a href={item.href}>{item.label}</a></p>})}</div></section>}
|
||||
{components.map((component) => (
|
||||
<div class="formula-component" key={component.id}>
|
||||
{components.length > 1 && <h3>{component.name}</h3>}
|
||||
{Boolean(component.name?.trim()) && <h3 class="formula-component-heading">{component.name}</h3>}
|
||||
{(component.notes ?? []).filter(Boolean).map((note, noteIdx) => (
|
||||
<p key={noteIdx} class="formula-component-note">{note}</p>
|
||||
))}
|
||||
<div class="table-wrap">
|
||||
<table class="recipe-ingredients">
|
||||
<thead><tr><th>Ingredient</th>{calculatePercent&&<th>{percentMode==="standard"?"Standard %":"Baker's %"}</th>}<th>Weight</th></tr></thead>
|
||||
<thead><tr><th>Amount</th><th>Ingredient</th>{calculatePercent&&<th>{percentMode==="standard"?"Standard %":"Baker's %"}</th>}</tr></thead>
|
||||
<tbody>
|
||||
{component.items.filter(item=>percentMode!=="bakers"||!calculatePercent||!item.basisMember).map((item) => {
|
||||
const displayUnitId = lineUnits[item.id] ?? item.amount.unit_id;
|
||||
@@ -119,13 +145,13 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
|
||||
};
|
||||
return (
|
||||
<tr key={item.id} class={item.basisMember ? "basis-row" : ""}>
|
||||
<td><span class="quantity-control line-quantity"><input aria-label={`${item.label} quantity`} type="number" min="0" step="any" value={roundForDisplay(displayQuantity)} onInput={(event) => changeLineQuantity(Number(event.currentTarget.value))}/><select aria-label={`${item.label} unit`} value={displayUnitId} style={{width:`${(compatibleUnits.find((unit) => unit.id === displayUnitId)?.symbol ?? displayUnitId).length + 0.75}ch`}} onChange={(event) => { const next = event.currentTarget.value; try { convertItem(item.amount.quantity * validFactor, item.amount.unit_id, next, item, units); } catch { event.currentTarget.value = displayUnitId; return; } setLineUnits((current) => ({ ...current, [item.id]: next })); }}>{compatibleUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}</select></span></td>
|
||||
<td>
|
||||
{item.href ? <a href={item.href}>{item.label}</a> : item.label}
|
||||
<span class="calculator-ingredient-name">{item.href ? <a href={item.href}>{item.label}</a> : item.label}{item.attention&&<span class="ingredient-attention-icon" role="img" aria-label={item.attentionMessage??"Needs attention"} title={item.attentionMessage??"Needs attention"}><svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><path fill="currentColor" d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg></span>}</span>
|
||||
{item.optional && <span class="muted"> optional</span>}
|
||||
{item.notes && <small>{item.notes}</small>}
|
||||
</td>
|
||||
{calculatePercent&&<td>{itemWeight(item)==null||percentageBase<=0?"—":`${number(itemWeight(item)!/percentageBase*100)}%`}</td>}
|
||||
<td><span class="quantity-control line-quantity"><input aria-label={`${item.label} quantity`} type="number" min="0" step="any" value={roundForDisplay(displayQuantity)} onInput={(event) => changeLineQuantity(Number(event.currentTarget.value))}/><select aria-label={`${item.label} unit`} value={displayUnitId} onChange={(event) => setLineUnits((current) => ({ ...current, [item.id]: event.currentTarget.value }))}>{compatibleUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}</select></span></td>
|
||||
{calculatePercent&&<td class="recipe-percent-cell">{itemWeight(item)==null||percentageBase<=0?"—":`${number(itemWeight(item)!/percentageBase*100)}%`}</td>}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -169,10 +195,121 @@ function useLiveFactor() {
|
||||
return factor;
|
||||
}
|
||||
|
||||
export function LiveCostValues({ cost }: { cost: CostResult }) {
|
||||
function CostLedgerLine({ line, factor, currency, editable, expanded }: { line:CostLine; factor:number; currency:string; editable:boolean; expanded:boolean }) {
|
||||
const money=new Intl.NumberFormat("en-US",{style:"currency",currency,minimumFractionDigits:2,maximumFractionDigits:4});
|
||||
const editHref = line.kind === "ingredient" ? `/app/ingredients/${line.subjectId}/?edit=1#costs` : `/app/recipes/${line.subjectId}/?edit=1#costing`;
|
||||
const viewHref = line.kind === "ingredient" ? `/app/ingredients/${line.subjectId}/#costs` : `/app/recipes/${line.subjectId}/#costing`;
|
||||
const hasChildren = Boolean(line.purchase || line.children?.length);
|
||||
const costValue = line.cost != null ? money.format(line.cost * factor) : "—";
|
||||
|
||||
const handleSummaryClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest("a") || target?.closest("button")) {
|
||||
return;
|
||||
}
|
||||
if (!hasChildren) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<details class={`cost-ledger-line ${hasChildren ? "has-children" : "flat"}`} open={expanded}>
|
||||
<summary onClick={handleSummaryClick}>
|
||||
<span class="cost-toggle-marker" aria-hidden="true">
|
||||
{hasChildren && (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" class="chevron-icon">
|
||||
<path fill="currentColor" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span class={`cost-subject-icon ${line.kind}`}>{line.kind==="recipe"?"R":"●"}</span>
|
||||
<a href={editable ? editHref : viewHref} class="cost-subject-name" onClick={(e) => e.stopPropagation()}>{line.name}</a>
|
||||
<span class="cost-attention-slot">
|
||||
{line.completeness < 1 && (
|
||||
<span class="cost-attention-badge" title="Cost information is incomplete (missing purchase item or price)" aria-label="Cost information is incomplete">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span class="cost-line-value">
|
||||
{line.kind === "ingredient" ? (
|
||||
<a href={editHref} class="cost-editable-link" title={line.cost != null ? "Edit purchase cost" : "Add purchase cost"} onClick={(e) => e.stopPropagation()}>
|
||||
<span>{line.cost != null ? costValue : "Add cost"}</span>
|
||||
<span class="cost-edit-icon" aria-hidden="true">✎</span>
|
||||
</a>
|
||||
) : (
|
||||
<span>{costValue}</span>
|
||||
)}
|
||||
</span>
|
||||
</summary>
|
||||
{hasChildren && (
|
||||
<div class="cost-line-detail">
|
||||
{line.purchase ? (
|
||||
<>
|
||||
<div><small>Purchase item name</small><strong>{line.purchase.name}</strong></div>
|
||||
<div><small>Purchase cost</small><strong>{money.format(line.purchase.price)}</strong></div>
|
||||
<div><small>Purchase unit</small><strong>{number(line.purchase.packageQuantity)} {line.purchase.packageUnitId}</strong></div>
|
||||
<div><small>Date added</small><strong>{line.purchase.effectiveAt}</strong></div>
|
||||
<div><small>Item ID #</small><strong>{line.purchase.sku??"—"}</strong></div>
|
||||
<div><small>Vendor</small><strong>{line.purchase.supplier??"—"}</strong></div>
|
||||
</>
|
||||
) : (
|
||||
<p>No usable purchase cost is available.</p>
|
||||
)}
|
||||
{line.children?.length ? (
|
||||
<div class="cost-child-lines">
|
||||
{line.children.map((child) => (
|
||||
<CostLedgerLine line={child} factor={factor} currency={currency} editable={editable} expanded={expanded}/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveCostValues({ cost, yieldQuantity, yieldUnit="g", editable=false }: { cost: CostResult; yieldQuantity?:number; yieldUnit?:string; editable?:boolean }) {
|
||||
const factor=useLiveFactor();
|
||||
const money=new Intl.NumberFormat("en-US",{style:"currency",currency:cost.currency,minimumFractionDigits:2,maximumFractionDigits:4});
|
||||
return <section class="derived-card"><div class="derived-title"><h2>Recipe Cost</h2><strong>{Math.round(cost.completeness*100)}% priced</strong></div>{cost.batch!=null?<dl><div><dt>Scaled batch</dt><dd>{money.format(cost.batch*factor)}</dd></div>{cost.perServing!=null&&<div><dt>Per serving</dt><dd>{money.format(cost.perServing*factor)}</dd></div>}{cost.per100g!=null&&<div><dt>Per 100 g</dt><dd>{money.format(cost.per100g)}</dd></div>}</dl>:<p>No usable purchase prices are available yet.</p>}{cost.completeness<1&&<p class="derived-warning">Partial estimate; unpriced ingredients are excluded.</p>}{cost.warnings.length>0&&<details class="cost-diagnostics"><summary>{cost.warnings.length} costing {cost.warnings.length===1?"issue":"issues"}</summary><ul>{cost.warnings.map(warning=><li>{warning}</li>)}</ul></details>}</section>;
|
||||
const [expansion,setExpansion]=useState({open:false,revision:0});
|
||||
const setAll=(open:boolean)=>setExpansion((current)=>({open,revision:current.revision+1}));
|
||||
|
||||
return (
|
||||
<section class="recipe-cost-ledger">
|
||||
<header>
|
||||
<h2>Recipe Cost</h2>
|
||||
<p>{editable?"Update an ingredient’s shared purchase cost here. The change is reflected in every recipe that uses it.":"Ingredient and sub-recipe costs used to calculate this recipe."}</p>
|
||||
</header>
|
||||
<div class="cost-ledger-heading">
|
||||
<span class="head-spacer" aria-hidden="true"></span>
|
||||
<span class="head-icon-spacer" aria-hidden="true"></span>
|
||||
<span class="head-subject">
|
||||
Ingredient / Sub-Recipe{" "}
|
||||
<button type="button" onClick={()=>setAll(true)}>Expand all</button>
|
||||
<i>|</i>
|
||||
<button type="button" onClick={()=>setAll(false)}>Collapse all</button>
|
||||
</span>
|
||||
<span class="head-attention-spacer" aria-hidden="true"></span>
|
||||
<span class="head-cost">Cost</span>
|
||||
</div>
|
||||
<div class={expansion.open?"cost-ledger-lines expand-all":"cost-ledger-lines"}>
|
||||
{cost.lines.map((line)=>(
|
||||
<CostLedgerLine key={`${line.id}:${expansion.revision}`} line={line} factor={factor} currency={cost.currency} editable={editable} expanded={expansion.open}/>
|
||||
))}
|
||||
</div>
|
||||
<div class="cost-summary">
|
||||
<div><strong>Total Yield</strong><span>{yieldQuantity!=null?number(yieldQuantity*factor):"—"} <small>{yieldUnit}</small></span></div>
|
||||
<div><strong>Total Cost</strong><span>{cost.batch!=null?money.format(cost.batch*factor):"—"}</span></div>
|
||||
<div><strong>Cost Per {yieldUnit.toUpperCase()}:</strong><span>{cost.batch!=null&&yieldQuantity?money.format(cost.batch/yieldQuantity):"—"}</span></div>
|
||||
{cost.perServing!=null&&<div><strong>Cost Per Serving</strong><span>{money.format(cost.perServing)}</span></div>}
|
||||
</div>
|
||||
{cost.completeness<1&&<p class="derived-warning">Partial estimate; unpriced ingredients are excluded. {Math.round(cost.completeness*100)}% of ingredient weight is priced.</p>}
|
||||
{cost.warnings.length>0&&<details class="cost-diagnostics"><summary>{cost.warnings.length} costing {cost.warnings.length===1?"issue":"issues"}</summary><ul>{cost.warnings.map(warning=><li>{warning}</li>)}</ul></details>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveNutritionValues({ nutrition,servings,ingredients=[],editable=false,saveVersion }: { nutrition:NutritionResult; servings?:number; ingredients?:import("./NutritionPanel").NutritionIngredientStatus[]; editable?:boolean; saveVersion?:number }) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
type Props = {
|
||||
open: boolean;
|
||||
text: string;
|
||||
error: string;
|
||||
parsing: boolean;
|
||||
onTextChange: (text: string) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export default function BulkIngredientImportModal({
|
||||
open,
|
||||
text,
|
||||
error,
|
||||
parsing,
|
||||
onTextChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
class="bulk-ingredient-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) onClose();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
class="bulk-ingredient-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bulk-ingredient-title"
|
||||
>
|
||||
<button
|
||||
class="bulk-dialog-close"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h2 id="bulk-ingredient-title">Add Ingredients</h2>
|
||||
<p>
|
||||
Type or copy/paste ingredients from a document, spreadsheet, PDF, or
|
||||
website.
|
||||
</p>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={text}
|
||||
onInput={(event) => onTextChange(event.currentTarget.value)}
|
||||
placeholder={
|
||||
"Dry Mix:\n500g flour\n1/2 cup semolina\nsalt to taste\n\nWet:\n5 cloves garlic\n3 egg yolks\nolive oil (room temp)"
|
||||
}
|
||||
/>
|
||||
{error && <p class="bulk-dialog-error">{error}</p>}
|
||||
<div class="bulk-entry-help">
|
||||
<span>
|
||||
Add headers <small>using a colon : eg To Garnish:</small>
|
||||
</span>
|
||||
<span>
|
||||
Add notes to ingredients <small>by putting them in (notes)</small>
|
||||
</span>
|
||||
</div>
|
||||
<footer>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-cancel"
|
||||
disabled={parsing}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-submit"
|
||||
disabled={!text.trim() || parsing}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{parsing ? "Parsing…" : "Add Ingredients"}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
type Props = {
|
||||
open: boolean;
|
||||
text: string;
|
||||
error: string;
|
||||
onTextChange: (text: string) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export default function BulkPrepStepsModal({
|
||||
open,
|
||||
text,
|
||||
error,
|
||||
onTextChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
class="bulk-ingredient-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) onClose();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
class="bulk-ingredient-dialog bulk-prep-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bulk-prep-title"
|
||||
>
|
||||
<button
|
||||
class="bulk-dialog-close"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h2 id="bulk-prep-title">Add Prep Steps</h2>
|
||||
<p>
|
||||
Type or copy/paste prep steps from a document, spreadsheet, PDF, or
|
||||
website.
|
||||
</p>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={text}
|
||||
onInput={(event) => onTextChange(event.currentTarget.value)}
|
||||
placeholder={
|
||||
'Cut & Portion Pork Belly:\n1. Dice pork belly into 1" cubes\n2. Transfer into container\n\nTo Marinate:\n3. Mix gochujang, mustard, and oil\n4. Coat pork thoroughly and refrigerate'
|
||||
}
|
||||
/>
|
||||
{error && <p class="bulk-dialog-error">{error}</p>}
|
||||
<div class="bulk-entry-help">
|
||||
<span>
|
||||
Add headers <small>using a colon : eg To Sear:</small>
|
||||
</span>
|
||||
<span>
|
||||
Add notes to prep method <small>by putting them in (notes)</small>
|
||||
</span>
|
||||
</div>
|
||||
<footer>
|
||||
<button type="button" class="bulk-cancel" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-submit"
|
||||
disabled={!text.trim()}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Add Prep Steps
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import type { RecipeStructure } from "../../lib/database";
|
||||
import type { DragState, Option, PercentMode } from "./types";
|
||||
|
||||
type Props = {
|
||||
item: RecipeStructure["components"][number]["items"][number];
|
||||
componentIndex: number;
|
||||
itemIndex: number;
|
||||
units: Array<Option & { symbol: string }>;
|
||||
ingredients: Option[];
|
||||
pendingIngredients: Option[];
|
||||
recipes: Option[];
|
||||
recipeId: string;
|
||||
dragging: DragState;
|
||||
calculatePercent: boolean;
|
||||
percentMode: PercentMode;
|
||||
percentValue: number | undefined;
|
||||
focusedItem: string | undefined;
|
||||
rowQueryValue: string | undefined;
|
||||
onUpdateQuantity: (quantity: number) => void;
|
||||
onUpdateUnit: (unitId: string) => void;
|
||||
onUpdateNotes: (notes: string) => void;
|
||||
onUpdateBasis: (basis: boolean) => void;
|
||||
onRemove: () => void;
|
||||
onDragStart: (event: DragEvent) => void;
|
||||
onDrop: (event: DragEvent) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onSelectChoice: (
|
||||
kind: "ingredient" | "recipe",
|
||||
id: string,
|
||||
name: string
|
||||
) => void;
|
||||
onCreatePendingIngredient: (name: string) => void;
|
||||
onSetPercent: (target: number) => void;
|
||||
};
|
||||
|
||||
const normal = (value: string) =>
|
||||
value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
|
||||
export default function RecipeItemRow({
|
||||
item,
|
||||
componentIndex,
|
||||
itemIndex,
|
||||
units,
|
||||
ingredients,
|
||||
pendingIngredients,
|
||||
recipes,
|
||||
recipeId,
|
||||
dragging,
|
||||
calculatePercent,
|
||||
percentMode,
|
||||
percentValue,
|
||||
focusedItem,
|
||||
rowQueryValue,
|
||||
onUpdateQuantity,
|
||||
onUpdateUnit,
|
||||
onUpdateNotes,
|
||||
onUpdateBasis,
|
||||
onRemove,
|
||||
onDragStart,
|
||||
onDrop,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onQueryChange,
|
||||
onSelectChoice,
|
||||
onCreatePendingIngredient,
|
||||
onSetPercent,
|
||||
}: Props) {
|
||||
const isPending = Boolean(
|
||||
item.ingredient_id &&
|
||||
pendingIngredients.some((entry) => entry.id === item.ingredient_id)
|
||||
);
|
||||
const label = item.ingredient_id
|
||||
? [...ingredients, ...pendingIngredients].find(
|
||||
(entry) => entry.id === item.ingredient_id
|
||||
)?.name
|
||||
: recipes.find((entry) => entry.id === item.subrecipe_id)?.name;
|
||||
const typedValue = rowQueryValue ?? label ?? "";
|
||||
const choices = [
|
||||
...[...ingredients, ...pendingIngredients].map((entry) => ({
|
||||
...entry,
|
||||
kind: "ingredient" as const,
|
||||
})),
|
||||
...recipes
|
||||
.filter((entry) => entry.id !== recipeId)
|
||||
.map((entry) => ({ ...entry, kind: "recipe" as const })),
|
||||
];
|
||||
const exactMatch = choices.some((entry) =>
|
||||
[entry.name, ...(entry.aliases ?? [])].some(
|
||||
(name) => normal(name) === normal(typedValue)
|
||||
)
|
||||
);
|
||||
const shownChoices = choices
|
||||
.filter(
|
||||
(entry) =>
|
||||
!typedValue.trim() ||
|
||||
[entry.name, ...(entry.aliases ?? [])].some((name) =>
|
||||
normal(name).includes(normal(typedValue))
|
||||
)
|
||||
)
|
||||
.slice(0, 10);
|
||||
const hasUncommittedInput = normal(typedValue) !== normal(label ?? "");
|
||||
const canCreateInput = Boolean(typedValue.trim()) && !exactMatch;
|
||||
const isSubrecipe = Boolean(item.subrecipe_id);
|
||||
|
||||
return (
|
||||
<tr
|
||||
class={`${
|
||||
dragging?.kind === "item" &&
|
||||
dragging.component === componentIndex &&
|
||||
dragging.index === itemIndex
|
||||
? "dragging"
|
||||
: ""
|
||||
}${isPending ? " pending-ingredient-row" : ""}${
|
||||
isSubrecipe ? " is-subrecipe-row" : ""
|
||||
}`}
|
||||
key={item.id}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<td class="quantity-column">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
aria-label="Quantity"
|
||||
value={item.quantity}
|
||||
onInput={(event) =>
|
||||
onUpdateQuantity(Number(event.currentTarget.value))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td class="unit-column">
|
||||
<select
|
||||
aria-label="Unit"
|
||||
value={item.unit_id}
|
||||
onChange={(event) => onUpdateUnit(event.currentTarget.value)}
|
||||
>
|
||||
{units.map((unit) => (
|
||||
<option value={unit.id} key={unit.id}>
|
||||
{unit.symbol || unit.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td class="entity-column">
|
||||
<div
|
||||
class={`row-entity-combobox${
|
||||
hasUncommittedInput ? " unmatched" : ""
|
||||
}${isSubrecipe ? " is-subrecipe" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={typedValue}
|
||||
placeholder="Ingredient or Recipe"
|
||||
aria-label={`Ingredient or recipe for row ${itemIndex + 1}`}
|
||||
aria-expanded={focusedItem === item.id}
|
||||
aria-autocomplete="list"
|
||||
onFocus={(event) => {
|
||||
onFocus();
|
||||
event.currentTarget.select();
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
onInput={(event) => onQueryChange(event.currentTarget.value)}
|
||||
/>
|
||||
{isPending && (
|
||||
<span
|
||||
class="ingredient-attention-icon"
|
||||
aria-label="New unmatched ingredient"
|
||||
title="New ingredient; details can be completed after saving"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="17"
|
||||
height="17"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{focusedItem === item.id && (
|
||||
<div class="row-entity-results" role="listbox">
|
||||
{shownChoices.map((entry) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${entry.kind}-${entry.id}`}
|
||||
role="option"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() =>
|
||||
onSelectChoice(entry.kind, entry.id, entry.name)
|
||||
}
|
||||
>
|
||||
<strong>{entry.name}</strong>
|
||||
<small class={`badge-${entry.kind}`}>
|
||||
{entry.kind === "recipe" ? "Recipe" : "Ingredient"}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
{canCreateInput && (
|
||||
<div class="row-create-actions">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onCreatePendingIngredient(typedValue)}
|
||||
>
|
||||
+ Create “{typedValue}”
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isPending && (
|
||||
<small class="pending-ingredient-help">
|
||||
New ingredient · saved automatically
|
||||
</small>
|
||||
)}
|
||||
</td>
|
||||
<td class="notes-column">
|
||||
<input
|
||||
class="line-notes"
|
||||
aria-label={`${label} notes`}
|
||||
value={item.notes ?? ""}
|
||||
placeholder="Add Notes"
|
||||
onInput={(event) => onUpdateNotes(event.currentTarget.value)}
|
||||
/>
|
||||
</td>
|
||||
{calculatePercent && percentMode === "bakers" && (
|
||||
<td class="base-column">
|
||||
<input
|
||||
aria-label={`${label} baker's base member`}
|
||||
type="checkbox"
|
||||
checked={item.basis_member}
|
||||
onChange={(event) => onUpdateBasis(event.currentTarget.checked)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{calculatePercent && (
|
||||
<td class="percent-column">
|
||||
{percentValue == null ? (
|
||||
<span title="A weight equivalency is required">—</span>
|
||||
) : (
|
||||
<input
|
||||
aria-label={`${label} percentage`}
|
||||
type="number"
|
||||
min="0"
|
||||
max={
|
||||
percentMode === "standard" || item.basis_member
|
||||
? 99.999
|
||||
: undefined
|
||||
}
|
||||
step="any"
|
||||
value={Math.round(percentValue * 1000) / 1000}
|
||||
onInput={(event) => onSetPercent(Number(event.currentTarget.value))}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td class="actions-column">
|
||||
<div class="row-actions-group">
|
||||
<button
|
||||
type="button"
|
||||
class="remove-row-btn"
|
||||
title="Delete"
|
||||
aria-label="Delete ingredient"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="drag-handle"
|
||||
title="Drag to reorder"
|
||||
aria-label="Drag to reorder"
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="22"
|
||||
height="22"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { createPortal } from "preact/compat";
|
||||
import type { RecipeStructure } from "../../lib/database";
|
||||
import type { DragState } from "./types";
|
||||
|
||||
type Props = {
|
||||
steps: RecipeStructure["steps"];
|
||||
methodTarget: HTMLElement | null;
|
||||
dragging: DragState;
|
||||
onStepsChange: (steps: RecipeStructure["steps"]) => void;
|
||||
onDragStart: (index: number) => void;
|
||||
onDrop: (fromIndex: number, toIndex: number) => void;
|
||||
onOpenBulkPrep: () => void;
|
||||
};
|
||||
|
||||
const uid = (prefix: string) => `${prefix}_${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
export default function RecipeMethodEditor({
|
||||
steps,
|
||||
methodTarget,
|
||||
dragging,
|
||||
onStepsChange,
|
||||
onDragStart,
|
||||
onDrop,
|
||||
onOpenBulkPrep,
|
||||
}: Props) {
|
||||
const content = (
|
||||
<section class="method-editor">
|
||||
<div class="method-header-row">
|
||||
<h2>
|
||||
Prep Method <small>{steps.length}</small>
|
||||
</h2>
|
||||
</div>
|
||||
<ol class="method-steps-list">
|
||||
{steps.map((step, index) => {
|
||||
const stepKind = step.instruction.trim().endsWith(":")
|
||||
? " prep-heading"
|
||||
: /^\(.+\)$/.test(step.instruction.trim())
|
||||
? " prep-note"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<li
|
||||
class={`method-step-card${
|
||||
dragging?.kind === "step" && dragging.index === index
|
||||
? " dragging"
|
||||
: ""
|
||||
}${stepKind}`}
|
||||
key={step.id}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={() => {
|
||||
if (dragging?.kind === "step") onDrop(dragging.index, index);
|
||||
}}
|
||||
>
|
||||
<span class="step-num">{index + 1}.</span>
|
||||
<div class="step-body">
|
||||
<textarea
|
||||
rows={2}
|
||||
class="step-textarea"
|
||||
placeholder="Add Prep Step"
|
||||
value={step.instruction}
|
||||
onInput={(event) =>
|
||||
onStepsChange(
|
||||
steps.map((value, position) =>
|
||||
position === index
|
||||
? { ...value, instruction: event.currentTarget.value }
|
||||
: value
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="step-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="remove-step-btn"
|
||||
title="Delete step"
|
||||
onClick={() =>
|
||||
onStepsChange(
|
||||
steps.filter((_, position) => position !== index)
|
||||
)
|
||||
}
|
||||
disabled={steps.length === 1}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="drag-handle"
|
||||
title="Drag to reorder"
|
||||
draggable
|
||||
onDragStart={() => onDragStart(index)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="22"
|
||||
height="22"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<div class="method-footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="action-link-btn"
|
||||
onClick={() =>
|
||||
onStepsChange([
|
||||
...steps,
|
||||
{ id: uid("step"), instruction: "New Section:", equipment_ids: [] },
|
||||
])
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z" />
|
||||
</svg>
|
||||
Add Header
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-link-btn"
|
||||
onClick={() =>
|
||||
onStepsChange([
|
||||
...steps,
|
||||
{ id: uid("step"), instruction: "(Note)", equipment_ids: [] },
|
||||
])
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z" />
|
||||
</svg>
|
||||
Add Note
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-prep-btn"
|
||||
onClick={onOpenBulkPrep}
|
||||
>
|
||||
Add Prep Steps
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
return methodTarget ? createPortal(content, methodTarget) : content;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./types";
|
||||
export * from "./useRecipeStructure";
|
||||
export { default as RecipeItemRow } from "./RecipeItemRow";
|
||||
export { default as RecipeMethodEditor } from "./RecipeMethodEditor";
|
||||
export { default as BulkIngredientImportModal } from "./BulkIngredientImportModal";
|
||||
export { default as BulkPrepStepsModal } from "./BulkPrepStepsModal";
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { RecipeStructure } from "../../lib/database";
|
||||
|
||||
export type Option = { id: string; name: string; aliases?: string[] };
|
||||
|
||||
export type RecipeEditorProps = {
|
||||
recipeId: string;
|
||||
initial: RecipeStructure;
|
||||
ingredients: Option[];
|
||||
recipes: Option[];
|
||||
units: Array<Option & { symbol: string }>;
|
||||
prepActions: Option[];
|
||||
autoYield?: boolean;
|
||||
showMethod?: boolean;
|
||||
weightRates?: Record<string, number | null>;
|
||||
};
|
||||
|
||||
export type DragState =
|
||||
| { kind: "component" | "item" | "step"; component?: number; index: number }
|
||||
| undefined;
|
||||
|
||||
export type EditorState = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
export type PercentMode = "standard" | "bakers";
|
||||
@@ -0,0 +1,676 @@
|
||||
import { useState, useEffect } from "preact/hooks";
|
||||
import type { RecipeStructure } from "../../lib/database";
|
||||
import { percentage, targetWeight } from "../../lib/percentages";
|
||||
import type {
|
||||
DragState,
|
||||
EditorState,
|
||||
Option,
|
||||
PercentMode,
|
||||
RecipeEditorProps,
|
||||
} from "./types";
|
||||
|
||||
export const uid = (prefix: string) =>
|
||||
`${prefix}_${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
export const normal = (value: string) =>
|
||||
value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
|
||||
const unitAliases: Record<string, string> = {
|
||||
g: "gram", gram: "gram", grams: "gram", kg: "kilogram", kilogram: "kilogram", kilograms: "kilogram",
|
||||
oz: "ounce_mass", ounce: "ounce_mass", ounces: "ounce_mass", lb: "pound", lbs: "pound", pound: "pound", pounds: "pound",
|
||||
tsp: "teaspoon_us", teaspoon: "teaspoon_us", teaspoons: "teaspoon_us", tbsp: "tablespoon_us", tablespoon: "tablespoon_us", tablespoons: "tablespoon_us",
|
||||
c: "cup_us", cup: "cup_us", cups: "cup_us", ml: "milliliter", milliliter: "milliliter", milliliters: "milliliter",
|
||||
l: "liter", liter: "liter", liters: "liter", ea: "each", each: "each", clove: "each", cloves: "each",
|
||||
};
|
||||
|
||||
export function useRecipeStructure({
|
||||
recipeId,
|
||||
initial,
|
||||
ingredients,
|
||||
recipes,
|
||||
units,
|
||||
autoYield = false,
|
||||
weightRates = {},
|
||||
}: RecipeEditorProps) {
|
||||
const [data, setData] = useState(initial);
|
||||
const [query, setQuery] = useState<Record<string, string>>({});
|
||||
const [rowQuery, setRowQuery] = useState<Record<string, string>>({});
|
||||
const [focusedItem, setFocusedItem] = useState<string>();
|
||||
const [draftNotes, setDraftNotes] = useState<Record<string, string>>({});
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [bulkText, setBulkText] = useState("");
|
||||
const [bulkError, setBulkError] = useState("");
|
||||
const [bulkParsing, setBulkParsing] = useState(false);
|
||||
const [prepOpen, setPrepOpen] = useState(false);
|
||||
const [prepText, setPrepText] = useState("");
|
||||
const [prepError, setPrepError] = useState("");
|
||||
const [pendingIngredients, setPendingIngredients] = useState<Option[]>([]);
|
||||
const [methodTarget, setMethodTarget] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMethodTarget(document.getElementById("recipe-method-editor-slot"));
|
||||
}, []);
|
||||
|
||||
const [baseline, setBaseline] = useState(JSON.stringify(initial));
|
||||
const [state, setState] = useState<EditorState>("idle");
|
||||
const [message, setMessage] = useState("");
|
||||
const [dragging, setDragging] = useState<DragState>();
|
||||
const [calculatePercent, setCalculatePercent] = useState(true);
|
||||
const [percentMode, setPercentMode] = useState<PercentMode>("standard");
|
||||
|
||||
const updateComponent = (
|
||||
index: number,
|
||||
update: (
|
||||
component: RecipeStructure["components"][number]
|
||||
) => RecipeStructure["components"][number]
|
||||
) =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
components: current.components.map((component, position) =>
|
||||
position === index ? update(component) : component
|
||||
),
|
||||
}));
|
||||
|
||||
const allItems = () =>
|
||||
data.components.flatMap((component) => component.items);
|
||||
|
||||
const rate = (
|
||||
item: RecipeStructure["components"][number]["items"][number]
|
||||
) =>
|
||||
item.unit_id === "gram"
|
||||
? 1
|
||||
: weightRates[
|
||||
`${
|
||||
item.ingredient_id
|
||||
? `ingredient:${item.ingredient_id}`
|
||||
: `recipe:${item.subrecipe_id}`
|
||||
}:${item.unit_id}`
|
||||
];
|
||||
|
||||
const weight = (
|
||||
item: RecipeStructure["components"][number]["items"][number]
|
||||
) => {
|
||||
const value = rate(item);
|
||||
return value == null ? undefined : item.quantity * value;
|
||||
};
|
||||
|
||||
const calculatedYieldWeight = () =>
|
||||
allItems().reduce((sum, item) => sum + (weight(item) ?? 0), 0);
|
||||
|
||||
const unconvertedYieldItems = () =>
|
||||
allItems().filter((item) => weight(item) == null).length;
|
||||
|
||||
const denominator = () =>
|
||||
percentMode === "standard"
|
||||
? allItems().reduce((sum, item) => sum + (weight(item) ?? 0), 0)
|
||||
: allItems()
|
||||
.filter((item) => item.basis_member)
|
||||
.reduce((sum, item) => sum + (weight(item) ?? 0), 0);
|
||||
|
||||
const percent = (
|
||||
item: RecipeStructure["components"][number]["items"][number]
|
||||
) => percentage(weight(item), denominator());
|
||||
|
||||
const setPercent = (
|
||||
componentIndex: number,
|
||||
itemIndex: number,
|
||||
target: number
|
||||
) => {
|
||||
const item = data.components[componentIndex].items[itemIndex],
|
||||
itemRate = rate(item),
|
||||
grams = targetWeight(
|
||||
target,
|
||||
weight(item) ?? 0,
|
||||
denominator(),
|
||||
percentMode === "standard" || item.basis_member
|
||||
);
|
||||
if (itemRate == null || grams == null) return;
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex ? { ...line, quantity: grams / itemRate } : line
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const move = <T,>(items: T[], from: number, to: number) => {
|
||||
const result = [...items];
|
||||
const [entry] = result.splice(from, 1);
|
||||
result.splice(to, 0, entry);
|
||||
return result;
|
||||
};
|
||||
|
||||
const addLine = (componentIndex: number, selected: string) => {
|
||||
const componentId = data.components[componentIndex].id;
|
||||
const [kind, id] = selected.split(":", 2);
|
||||
updateComponent(componentIndex, (component) => ({
|
||||
...component,
|
||||
items: [
|
||||
...component.items,
|
||||
{
|
||||
id: uid("line"),
|
||||
...(kind === "ingredient"
|
||||
? { ingredient_id: id }
|
||||
: { subrecipe_id: id }),
|
||||
quantity: 1,
|
||||
unit_id: "gram",
|
||||
basis_member: false,
|
||||
optional: false,
|
||||
nutrition_retention_factor: 1,
|
||||
prep: [],
|
||||
...(draftNotes[componentId]?.trim()
|
||||
? { notes: draftNotes[componentId].trim() }
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
}));
|
||||
setQuery((current) => ({
|
||||
...current,
|
||||
[data.components[componentIndex].id]: "",
|
||||
}));
|
||||
setDraftNotes((current) => ({ ...current, [componentId]: "" }));
|
||||
};
|
||||
|
||||
const replaceLineReference = (
|
||||
componentIndex: number,
|
||||
itemIndex: number,
|
||||
kind: "ingredient" | "recipe",
|
||||
id: string,
|
||||
name: string
|
||||
) => {
|
||||
const itemId = data.components[componentIndex].items[itemIndex].id;
|
||||
updateComponent(componentIndex, (component) => ({
|
||||
...component,
|
||||
items: component.items.map((line, index) =>
|
||||
index === itemIndex
|
||||
? {
|
||||
...line,
|
||||
ingredient_id: kind === "ingredient" ? id : undefined,
|
||||
subrecipe_id: kind === "recipe" ? id : undefined,
|
||||
}
|
||||
: line
|
||||
),
|
||||
}));
|
||||
setRowQuery((current) => ({ ...current, [itemId]: name }));
|
||||
setFocusedItem(undefined);
|
||||
};
|
||||
|
||||
const createPendingRowIngredient = (
|
||||
componentIndex: number,
|
||||
itemIndex: number,
|
||||
name: string
|
||||
) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const existing = [...ingredients, ...pendingIngredients].find(
|
||||
(ingredient) => normal(ingredient.name) === normal(trimmed)
|
||||
);
|
||||
if (existing) {
|
||||
replaceLineReference(
|
||||
componentIndex,
|
||||
itemIndex,
|
||||
"ingredient",
|
||||
existing.id,
|
||||
existing.name
|
||||
);
|
||||
return;
|
||||
}
|
||||
const takenIds = new Set(
|
||||
[...ingredients, ...pendingIngredients].map((ingredient) => ingredient.id)
|
||||
);
|
||||
const base = normal(trimmed).replace(/ /g, "_") || "new_ingredient";
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (takenIds.has(id)) id = `${base}_${suffix++}`;
|
||||
setPendingIngredients((current) => [...current, { id, name: trimmed }]);
|
||||
replaceLineReference(componentIndex, itemIndex, "ingredient", id, trimmed);
|
||||
};
|
||||
|
||||
const createPendingIngredient = (componentIndex: number, name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const existing = [...ingredients, ...pendingIngredients].find(
|
||||
(ingredient) => normal(ingredient.name) === normal(trimmed)
|
||||
);
|
||||
if (existing) {
|
||||
addLine(componentIndex, `ingredient:${existing.id}`);
|
||||
return;
|
||||
}
|
||||
const takenIds = new Set(
|
||||
[...ingredients, ...pendingIngredients].map((ingredient) => ingredient.id)
|
||||
);
|
||||
const base = normal(trimmed).replace(/ /g, "_") || "new_ingredient";
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (takenIds.has(id)) id = `${base}_${suffix++}`;
|
||||
setPendingIngredients((current) => [...current, { id, name: trimmed }]);
|
||||
addLine(componentIndex, `ingredient:${id}`);
|
||||
};
|
||||
|
||||
const identityTokens = (value: string) =>
|
||||
normal(value)
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((token) =>
|
||||
token.length > 3 && token.endsWith("s") ? token.slice(0, -1) : token
|
||||
);
|
||||
|
||||
const addBulkIngredients = async () => {
|
||||
if (!bulkText.trim()) {
|
||||
setBulkError("Enter at least one ingredient.");
|
||||
return;
|
||||
}
|
||||
setBulkParsing(true);
|
||||
setBulkError("");
|
||||
let parsed: {
|
||||
components: Array<{
|
||||
name: string;
|
||||
items: Array<{
|
||||
source_line: string;
|
||||
quantity: number | null;
|
||||
unit: string | null;
|
||||
ingredient: string;
|
||||
preparation: string | null;
|
||||
note: string | null;
|
||||
optional: boolean;
|
||||
alternatives: string[];
|
||||
}>;
|
||||
}>;
|
||||
warnings: string[];
|
||||
};
|
||||
try {
|
||||
const response = await fetch("/api/app/recipes/parse-ingredients", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ text: bulkText }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(result.error ?? "Unable to parse ingredients.");
|
||||
parsed = result;
|
||||
} catch (error) {
|
||||
setBulkError(
|
||||
error instanceof Error ? error.message : "Unable to parse ingredients."
|
||||
);
|
||||
setBulkParsing(false);
|
||||
return;
|
||||
}
|
||||
const available = [
|
||||
...[...ingredients, ...pendingIngredients].map((item) => ({
|
||||
...item,
|
||||
kind: "ingredient" as const,
|
||||
})),
|
||||
...recipes
|
||||
.filter((item) => item.id !== recipeId)
|
||||
.map((item) => ({ ...item, kind: "recipe" as const })),
|
||||
];
|
||||
const components: RecipeStructure["components"] = [];
|
||||
const unmatched: string[] = [];
|
||||
const created: Option[] = [];
|
||||
const takenIds = new Set(
|
||||
[...ingredients, ...pendingIngredients].map((item) => item.id)
|
||||
);
|
||||
const newIngredient = (name: string) => {
|
||||
const base = normal(name).replace(/ /g, "_") || "imported_ingredient";
|
||||
let id = base,
|
||||
suffix = 2;
|
||||
while (takenIds.has(id)) id = `${base}_${suffix++}`;
|
||||
takenIds.add(id);
|
||||
const ingredient = { id, name: name.trim() };
|
||||
created.push(ingredient);
|
||||
const option = { ...ingredient, kind: "ingredient" as const };
|
||||
available.push(option);
|
||||
return option;
|
||||
};
|
||||
for (const parsedComponent of parsed.components) {
|
||||
const component = {
|
||||
id: uid("component"),
|
||||
name: parsedComponent.name || "Main",
|
||||
items: [],
|
||||
notes: [],
|
||||
} as RecipeStructure["components"][number];
|
||||
components.push(component);
|
||||
for (const parsedItem of parsedComponent.items) {
|
||||
const wanted = normal(parsedItem.ingredient);
|
||||
const tokens = identityTokens(wanted);
|
||||
const reducedTokens = tokens.filter(
|
||||
(token) =>
|
||||
![
|
||||
"fresh",
|
||||
"dried",
|
||||
"flake",
|
||||
"leave",
|
||||
"chopped",
|
||||
"minced",
|
||||
"sliced",
|
||||
"crushed",
|
||||
"granulated",
|
||||
].includes(token)
|
||||
);
|
||||
const labels = (item: (typeof available)[number]) =>
|
||||
[item.name, ...(item.aliases ?? [])].map(normal);
|
||||
const labelTokens = (item: (typeof available)[number]) =>
|
||||
labels(item).map(identityTokens);
|
||||
const match =
|
||||
available.find((item) => labels(item).includes(wanted)) ??
|
||||
available.find((item) =>
|
||||
labels(item).some(
|
||||
(label) => label.includes(wanted) || wanted.includes(label)
|
||||
)
|
||||
) ??
|
||||
available.find((item) =>
|
||||
labelTokens(item).some((label) =>
|
||||
tokens.every((token) => label.includes(token))
|
||||
)
|
||||
) ??
|
||||
available.find(
|
||||
(item) =>
|
||||
reducedTokens.length &&
|
||||
labelTokens(item).some((label) =>
|
||||
reducedTokens.every((token) => label.includes(token))
|
||||
)
|
||||
) ??
|
||||
newIngredient(parsedItem.ingredient);
|
||||
const unitId = parsedItem.unit
|
||||
? unitAliases[normal(parsedItem.unit)]
|
||||
: "each";
|
||||
if (!match || !unitId || !units.some((unit) => unit.id === unitId)) {
|
||||
unmatched.push(parsedItem.source_line);
|
||||
continue;
|
||||
}
|
||||
const notes = [
|
||||
parsedItem.preparation,
|
||||
parsedItem.note,
|
||||
parsedItem.alternatives.length
|
||||
? `Alternatives: ${parsedItem.alternatives.join("; ")}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
component.items.push({
|
||||
id: uid("line"),
|
||||
...(match.kind === "ingredient"
|
||||
? { ingredient_id: match.id }
|
||||
: { subrecipe_id: match.id }),
|
||||
quantity: parsedItem.quantity ?? 1,
|
||||
unit_id: unitId,
|
||||
basis_member: false,
|
||||
optional: parsedItem.optional,
|
||||
nutrition_retention_factor: 1,
|
||||
prep: [],
|
||||
...(notes ? { notes } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
const populated = components.filter((entry) => entry.items.length > 0);
|
||||
if (unmatched.length || !populated.length) {
|
||||
setBulkError(
|
||||
unmatched.length
|
||||
? `Could not match: ${unmatched.join("; ")}`
|
||||
: "No ingredients could be matched."
|
||||
);
|
||||
setBulkParsing(false);
|
||||
return;
|
||||
}
|
||||
setPendingIngredients((current) => [...current, ...created]);
|
||||
setData((current) => {
|
||||
const additions = [...populated];
|
||||
const currentComponents = current.components.map((component) => ({
|
||||
...component,
|
||||
items: [...component.items],
|
||||
}));
|
||||
if (
|
||||
additions[0]?.name.toLowerCase() === "main" &&
|
||||
currentComponents.length
|
||||
)
|
||||
currentComponents.at(-1)!.items.push(...additions.shift()!.items);
|
||||
return { ...current, components: [...currentComponents, ...additions] };
|
||||
});
|
||||
const creationMessage = created.length
|
||||
? `${created.length} new canonical ingredient${
|
||||
created.length === 1 ? " is" : "s are"
|
||||
} pending (${created.map((item) => item.name).join(", ")}) and will be created when you save. `
|
||||
: "";
|
||||
setMessage(
|
||||
`${creationMessage}${
|
||||
parsed.warnings.length
|
||||
? `Parser warnings: ${parsed.warnings.join(" ")}`
|
||||
: "Review imported ingredients before saving."
|
||||
}`
|
||||
);
|
||||
setBulkText("");
|
||||
setBulkError("");
|
||||
setBulkOpen(false);
|
||||
setBulkParsing(false);
|
||||
};
|
||||
|
||||
const addBulkPrepSteps = () => {
|
||||
const lines = prepText
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (!lines.length) {
|
||||
setPrepError("Field is required");
|
||||
return;
|
||||
}
|
||||
const additions = lines.map((instruction) => ({
|
||||
id: uid("step"),
|
||||
instruction,
|
||||
equipment_ids: [],
|
||||
}));
|
||||
setData((current) => ({
|
||||
...current,
|
||||
steps:
|
||||
current.steps.length === 1 && !current.steps[0].instruction.trim()
|
||||
? additions
|
||||
: [...current.steps, ...additions],
|
||||
}));
|
||||
setPrepText("");
|
||||
setPrepError("");
|
||||
setPrepOpen(false);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const unresolvedRow = data.components
|
||||
.flatMap((component) => component.items)
|
||||
.find((item) => {
|
||||
const typed = rowQuery[item.id];
|
||||
if (typed == null) return false;
|
||||
const currentLabel = item.ingredient_id
|
||||
? [...ingredients, ...pendingIngredients].find(
|
||||
(entry) => entry.id === item.ingredient_id
|
||||
)?.name
|
||||
: recipes.find((entry) => entry.id === item.subrecipe_id)?.name;
|
||||
return normal(typed) !== normal(currentLabel ?? "");
|
||||
});
|
||||
if (unresolvedRow) {
|
||||
setState("error");
|
||||
setMessage(
|
||||
"Choose a search result or create the unmatched ingredient before saving."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
setState("saving");
|
||||
setMessage("");
|
||||
const detailsForm =
|
||||
document.querySelector<HTMLFormElement>("#recipe-details-form");
|
||||
const details = detailsForm ? new FormData(detailsForm) : undefined;
|
||||
const additionalForm = document.querySelector<HTMLFormElement>(
|
||||
"#recipe-additional-form"
|
||||
);
|
||||
const additional = additionalForm ? new FormData(additionalForm) : undefined;
|
||||
const servingsText = String(details?.get("yield_servings") ?? "").trim();
|
||||
const shelfQuantityText = String(
|
||||
additional?.get("shelf_quantity") ?? ""
|
||||
).trim();
|
||||
const shelfUnit = String(additional?.get("shelf_unit") ?? "").trim();
|
||||
const payload = {
|
||||
...data,
|
||||
new_ingredients: pendingIngredients.map(({ id, name }) => ({ id, name })),
|
||||
...(details
|
||||
? {
|
||||
metadata: {
|
||||
title: String(details.get("title") ?? "").trim(),
|
||||
yield_quantity: Number(details.get("yield_quantity")),
|
||||
yield_unit_id: String(details.get("yield_unit_id") ?? ""),
|
||||
yield_servings: servingsText ? Number(servingsText) : null,
|
||||
yield_basis:
|
||||
String(details.get("yield_basis") ?? "").trim() || null,
|
||||
station: String(additional?.get("station") ?? "").trim() || null,
|
||||
cover_media_url:
|
||||
String(additional?.get("cover_media_url") ?? "").trim() || null,
|
||||
tags: String(additional?.get("tags") ?? "")
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
shelf_life:
|
||||
shelfQuantityText && shelfUnit
|
||||
? {
|
||||
quantity: Number(shelfQuantityText),
|
||||
unit: shelfUnit,
|
||||
storage_condition:
|
||||
String(
|
||||
additional?.get("storage_condition") ?? ""
|
||||
).trim() || undefined,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
components: data.components.map((component) => ({
|
||||
...component,
|
||||
items: component.items.map((item) => ({
|
||||
...item,
|
||||
...(calculatePercent ? { percentage: percent(item) } : {}),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
const response = await fetch(`/api/app/recipes/${recipeId}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
setState("error");
|
||||
setMessage(result.error ?? "Unable to save.");
|
||||
return false;
|
||||
}
|
||||
setData(result);
|
||||
setPendingIngredients([]);
|
||||
setBaseline(JSON.stringify(result));
|
||||
const saveVersionInput = document.querySelector<HTMLInputElement>(
|
||||
'#recipe-details-form input[name="save_version"]'
|
||||
);
|
||||
if (saveVersionInput) saveVersionInput.value = String(result.save_version);
|
||||
setState("saved");
|
||||
setMessage("Changes saved.");
|
||||
document.dispatchEvent(new CustomEvent("recipe:dirty", { detail: false }));
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleDone = (event: Event) => {
|
||||
const detail = (
|
||||
event as CustomEvent<{ complete: (saved: boolean) => void }>
|
||||
).detail;
|
||||
void save().then(detail.complete);
|
||||
};
|
||||
document.addEventListener("recipe:save-structure", handleDone);
|
||||
return () =>
|
||||
document.removeEventListener("recipe:save-structure", handleDone);
|
||||
}, [data, pendingIngredients, calculatePercent, percentMode]);
|
||||
|
||||
useEffect(() => {
|
||||
document.dispatchEvent(new CustomEvent("recipe:editor-ready"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bulkOpen && !prepOpen) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setBulkOpen(false);
|
||||
setPrepOpen(false);
|
||||
}
|
||||
};
|
||||
document.body.style.overflow = "hidden";
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [bulkOpen, prepOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("recipe:dirty", {
|
||||
detail:
|
||||
JSON.stringify(data) !== baseline || pendingIngredients.length > 0,
|
||||
})
|
||||
);
|
||||
}, [data, baseline, pendingIngredients]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoYield) return;
|
||||
const quantity = calculatedYieldWeight();
|
||||
const quantityInput = document.querySelector<HTMLInputElement>(
|
||||
'#recipe-details-form input[name="yield_quantity"]'
|
||||
);
|
||||
const unitInput = document.querySelector<HTMLSelectElement>(
|
||||
'#recipe-details-form select[name="yield_unit_id"]'
|
||||
);
|
||||
if (quantityInput && quantity > 0)
|
||||
quantityInput.value = String(Math.round(quantity * 1000) / 1000);
|
||||
if (unitInput) unitInput.value = "gram";
|
||||
}, [data, autoYield]);
|
||||
|
||||
return {
|
||||
data,
|
||||
setData,
|
||||
query,
|
||||
setQuery,
|
||||
rowQuery,
|
||||
setRowQuery,
|
||||
focusedItem,
|
||||
setFocusedItem,
|
||||
draftNotes,
|
||||
setDraftNotes,
|
||||
bulkOpen,
|
||||
setBulkOpen,
|
||||
bulkText,
|
||||
setBulkText,
|
||||
bulkError,
|
||||
setBulkError,
|
||||
bulkParsing,
|
||||
prepOpen,
|
||||
setPrepOpen,
|
||||
prepText,
|
||||
setPrepText,
|
||||
prepError,
|
||||
setPrepError,
|
||||
pendingIngredients,
|
||||
methodTarget,
|
||||
baseline,
|
||||
state,
|
||||
message,
|
||||
dragging,
|
||||
setDragging,
|
||||
calculatePercent,
|
||||
setCalculatePercent,
|
||||
percentMode,
|
||||
setPercentMode,
|
||||
updateComponent,
|
||||
percent,
|
||||
setPercent,
|
||||
move,
|
||||
addLine,
|
||||
replaceLineReference,
|
||||
createPendingRowIngredient,
|
||||
createPendingIngredient,
|
||||
addBulkIngredients,
|
||||
addBulkPrepSteps,
|
||||
save,
|
||||
unconvertedYieldItems,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { exportDatabase } from "./export-database";
|
||||
import { importDatabase } from "./import-database";
|
||||
import { validateBackupBundle } from "./validate-backup";
|
||||
import type { FormulationBackupBundle } from "./types";
|
||||
|
||||
function createTestDatabase(): DatabaseSync {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
const root = path.resolve(__dirname, "../../..");
|
||||
const migrationsDir = path.join(root, "migrations");
|
||||
const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort();
|
||||
for (const file of files) {
|
||||
const sql = fs.readFileSync(path.join(migrationsDir, file), "utf8");
|
||||
db.exec(sql);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("Database Backup & Restore System", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
|
||||
// Populate baseline sample data
|
||||
db.exec(`
|
||||
INSERT INTO units (id, name, symbol, dimension, system) VALUES
|
||||
('gram', 'Gram', 'g', 'mass', 'metric'),
|
||||
('kilogram', 'Kilogram', 'kg', 'mass', 'metric'),
|
||||
('each', 'Each', 'ea', 'count', 'customary');
|
||||
|
||||
INSERT INTO equipment (id, name, category, notes) VALUES
|
||||
('whisk', 'Whisk', 'hand_tool', 'Stainless steel');
|
||||
|
||||
INSERT INTO prep_actions (id, name, action_type, default_yield_factor) VALUES
|
||||
('dice', 'Dice', 'cut', 0.95);
|
||||
|
||||
INSERT INTO ingredients (id, schema_version, name, status, categories_json, tags_json, source_json) VALUES
|
||||
('flour', 2, 'Flour', 'active', '["dry"]', '["baking"]', '{}'),
|
||||
('sugar', 2, 'Sugar', 'active', '["dry"]', '["sweet"]', '{}');
|
||||
|
||||
INSERT INTO ingredient_aliases (ingredient_id, name, kind) VALUES
|
||||
('flour', 'All Purpose Flour', 'common');
|
||||
|
||||
INSERT INTO ingredient_prep_actions (ingredient_id, action_id, yield_factor) VALUES
|
||||
('flour', 'dice', 0.95);
|
||||
|
||||
INSERT INTO ingredient_measure_conversions (ingredient_id, id, from_quantity, from_unit_id, to_quantity, to_unit_id, source_json) VALUES
|
||||
('flour', 'conv_1', 1, 'each', 120, 'gram', '{}');
|
||||
|
||||
INSERT INTO recipes (id, schema_version, save_version, title, yield_quantity, yield_unit_id, source_json) VALUES
|
||||
('cake', 2, 1, 'Simple Cake', 500, 'gram', '{}');
|
||||
|
||||
INSERT INTO recipe_components (recipe_id, id, position, name) VALUES
|
||||
('cake', 'main', 1, 'Main');
|
||||
|
||||
INSERT INTO recipe_items (recipe_id, component_id, id, position, ingredient_id, quantity, unit_id) VALUES
|
||||
('cake', 'main', 'item_1', 1, 'flour', 300, 'gram'),
|
||||
('cake', 'main', 'item_2', 2, 'sugar', 200, 'gram');
|
||||
|
||||
INSERT INTO item_prep_actions (recipe_id, item_id, position, action_id, yield_factor) VALUES
|
||||
('cake', 'item_1', 1, 'dice', 1.0);
|
||||
|
||||
INSERT INTO recipe_steps (recipe_id, id, position, instruction) VALUES
|
||||
('cake', 'step_1', 1, 'Mix dry ingredients together.');
|
||||
|
||||
INSERT INTO purchase_items (id, ingredient_id, name, status, package_quantity, package_unit_id) VALUES
|
||||
('pi_flour_bag', 'flour', '50lb Flour Bag', 'active', 50, 'gram');
|
||||
|
||||
INSERT INTO price_observations (purchase_item_id, effective_at, currency, amount, source_json) VALUES
|
||||
('pi_flour_bag', '2026-08-01', 'USD', 24.50, '{}');
|
||||
|
||||
INSERT INTO collections (id, name, source_json) VALUES
|
||||
('bakery_menu', 'Bakery Menu', '{}');
|
||||
|
||||
INSERT INTO collection_recipes (collection_id, recipe_id, position) VALUES
|
||||
('bakery_menu', 'cake', 1);
|
||||
|
||||
INSERT INTO inventory_locations (id, name, position) VALUES
|
||||
('loc_dry', 'Dry Storage', 1);
|
||||
|
||||
INSERT INTO inventory_counts (id, title, counted_at, created_at) VALUES
|
||||
('count_aug', 'August Inventory', '2026-08-01', '2026-08-01');
|
||||
|
||||
INSERT INTO inventory_count_items (count_id, location_id, ingredient_id, quantity, unit_id, unit_cost, extended_cost) VALUES
|
||||
('count_aug', 'loc_dry', 'flour', 10, 'gram', 0.5, 5.0);
|
||||
`);
|
||||
});
|
||||
|
||||
it("exports all tables into a valid FormulationBackupBundle", () => {
|
||||
const bundle = exportDatabase(db);
|
||||
|
||||
expect(bundle.format_version).toBe("1.0.0");
|
||||
expect(bundle.app_version).toBe("2.0.0");
|
||||
expect(bundle.summary.units_count).toBe(3);
|
||||
expect(bundle.summary.ingredients_count).toBe(2);
|
||||
expect(bundle.summary.recipes_count).toBe(1);
|
||||
expect(bundle.summary.purchase_items_count).toBe(1);
|
||||
expect(bundle.summary.collections_count).toBe(1);
|
||||
expect(bundle.summary.inventory_locations_count).toBe(6); // 5 from baseline seed + 1 custom
|
||||
expect(bundle.summary.inventory_counts_count).toBe(1);
|
||||
|
||||
expect(bundle.data.recipes[0].items.length).toBe(2);
|
||||
expect(bundle.data.recipes[0].items[0].prep_actions.length).toBe(1);
|
||||
expect(bundle.data.ingredients[0].aliases.length).toBe(1);
|
||||
expect(bundle.data.purchase_items[0].prices.length).toBe(1);
|
||||
expect(bundle.data.inventory_counts[0].items.length).toBe(1);
|
||||
});
|
||||
|
||||
it("validates backup bundle structure correctly", () => {
|
||||
const bundle = exportDatabase(db);
|
||||
const result = validateBackupBundle(bundle);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
|
||||
const invalid = validateBackupBundle({ format_version: "2.0.0", data: {} });
|
||||
expect(invalid.valid).toBe(false);
|
||||
expect(invalid.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("performs full roundtrip export -> wipe -> import without data loss", () => {
|
||||
const originalBundle = exportDatabase(db);
|
||||
|
||||
const targetDb = createTestDatabase();
|
||||
// Wipe target db completely
|
||||
targetDb.exec("DELETE FROM units;");
|
||||
|
||||
const result = importDatabase(targetDb, originalBundle, { mode: "replace", rebuildProjections: false });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.summary.recipes_count).toBe(1);
|
||||
|
||||
const restoredBundle = exportDatabase(targetDb);
|
||||
expect(restoredBundle.summary.units_count).toBe(originalBundle.summary.units_count);
|
||||
expect(restoredBundle.summary.ingredients_count).toBe(originalBundle.summary.ingredients_count);
|
||||
expect(restoredBundle.summary.recipes_count).toBe(originalBundle.summary.recipes_count);
|
||||
expect(restoredBundle.data.recipes[0].title).toBe("Simple Cake");
|
||||
expect(restoredBundle.data.ingredients[0].aliases[0].name).toBe("All Purpose Flour");
|
||||
expect(restoredBundle.data.inventory_counts[0].items[0].quantity).toBe(10);
|
||||
});
|
||||
|
||||
it("supports merge mode without wiping existing unmentioned records", () => {
|
||||
const targetDb = createTestDatabase();
|
||||
targetDb.exec("INSERT INTO units (id, name, symbol, dimension, system) VALUES ('meter', 'Meter', 'm', 'length', 'metric');");
|
||||
|
||||
const bundle = exportDatabase(db);
|
||||
const result = importDatabase(targetDb, bundle, { mode: "merge", rebuildProjections: false });
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const units = targetDb.prepare("SELECT id FROM units ORDER BY id").all() as Array<{ id: string }>;
|
||||
expect(units.some((u) => u.id === "meter")).toBe(true);
|
||||
expect(units.some((u) => u.id === "gram")).toBe(true);
|
||||
});
|
||||
|
||||
it("rolls back transaction atomically if an error occurs during import", () => {
|
||||
const targetDb = createTestDatabase();
|
||||
targetDb.exec("INSERT INTO units (id, name, symbol, dimension, system) VALUES ('gram', 'Gram', 'g', 'mass', 'metric');");
|
||||
|
||||
const badBundle: FormulationBackupBundle = {
|
||||
$schema: "https://formulation.app/schemas/backup-v1.json",
|
||||
format_version: "1.0.0",
|
||||
app_version: "2.0.0",
|
||||
exported_at: new Date().toISOString(),
|
||||
summary: {
|
||||
units_count: 1, equipment_count: 0, prep_actions_count: 0,
|
||||
ingredients_count: 1, recipes_count: 0, purchase_items_count: 0,
|
||||
price_observations_count: 0, source_mappings_count: 0, collections_count: 0,
|
||||
inventory_locations_count: 0, inventory_counts_count: 0, total_records_count: 2
|
||||
},
|
||||
data: {
|
||||
units: [{ id: "gram", name: "Gram", symbol: "g", dimension: "mass", system: "metric", base_unit_id: null, factor: null, offset: null }],
|
||||
equipment: [],
|
||||
prep_actions: [],
|
||||
ingredients: [{
|
||||
id: "flour", schema_version: 2, name: "Flour", status: "active",
|
||||
categories_json: "INVALID JSON SYNTAX", tags_json: "[]", description: null, source_json: "{}", deleted_at: null,
|
||||
aliases: [], prep_actions: [], measure_conversions: [], density_measurements: []
|
||||
}],
|
||||
recipes: [{
|
||||
id: "bad_recipe", schema_version: 2, save_version: 1, title: "Bad", summary: null,
|
||||
categories_json: "[]", tags_json: "[]", yield_quantity: null as any, yield_unit_id: "gram",
|
||||
yield_servings: null, yield_basis: null, scaling_mode: null, scaling_basis_id: null,
|
||||
scaling_basis_quantity: null, scaling_basis_unit_id: null, notes_json: "[]",
|
||||
source_json: "{}", auto_yield: 0, station: null, deleted_at: null, cover_media_url: null,
|
||||
equipment_ids: [], components: [], items: [], steps: [], measure_conversions: [], media: []
|
||||
}],
|
||||
purchase_items: [],
|
||||
source_mappings: [],
|
||||
collections: [],
|
||||
inventory_locations: [],
|
||||
inventory_counts: [],
|
||||
}
|
||||
};
|
||||
|
||||
// Attempt importing badBundle which should fail validation/execution
|
||||
expect(() => {
|
||||
importDatabase(targetDb, badBundle, { mode: "replace", rebuildProjections: false });
|
||||
}).toThrow();
|
||||
|
||||
// Verify existing units record remains intact
|
||||
const units = targetDb.prepare("SELECT id FROM units").all() as Array<{ id: string }>;
|
||||
expect(units.length).toBe(1);
|
||||
expect(units[0].id).toBe("gram");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
FormulationBackupBundle,
|
||||
UnitBackupRecord,
|
||||
EquipmentBackupRecord,
|
||||
PrepActionBackupRecord,
|
||||
IngredientBackupRecord,
|
||||
RecipeBackupRecord,
|
||||
PurchaseItemBackupRecord,
|
||||
SourceMappingBackupRecord,
|
||||
CollectionBackupRecord,
|
||||
InventoryLocationBackupRecord,
|
||||
InventoryCountBackupRecord,
|
||||
BackupSummary,
|
||||
} from "./types";
|
||||
|
||||
export function exportDatabase(database: DatabaseSync): FormulationBackupBundle {
|
||||
// 1. Units
|
||||
const units = database
|
||||
.prepare("SELECT * FROM units ORDER BY dimension, id")
|
||||
.all() as unknown as UnitBackupRecord[];
|
||||
|
||||
// 2. Equipment
|
||||
const equipment = database
|
||||
.prepare("SELECT * FROM equipment ORDER BY id")
|
||||
.all() as unknown as EquipmentBackupRecord[];
|
||||
|
||||
// 3. Prep Actions
|
||||
const prepActions = database
|
||||
.prepare("SELECT * FROM prep_actions ORDER BY id")
|
||||
.all() as unknown as PrepActionBackupRecord[];
|
||||
|
||||
// 4. Ingredients & sub-tables
|
||||
const aliasStmt = database.prepare(
|
||||
"SELECT name, kind FROM ingredient_aliases WHERE ingredient_id = ? ORDER BY name"
|
||||
);
|
||||
const ingredientPrepStmt = database.prepare(
|
||||
"SELECT action_id, yield_factor, notes FROM ingredient_prep_actions WHERE ingredient_id = ? ORDER BY action_id"
|
||||
);
|
||||
const ingredientConvStmt = database.prepare(
|
||||
"SELECT id, from_quantity, from_unit_id, to_quantity, to_unit_id, state, source_json FROM ingredient_measure_conversions WHERE ingredient_id = ? ORDER BY id"
|
||||
);
|
||||
const ingredientDensityStmt = database.prepare(
|
||||
"SELECT id, mass_quantity, mass_unit_id, volume_quantity, volume_unit_id, temperature_c, state, source_json FROM ingredient_density_measurements WHERE ingredient_id = ? ORDER BY id"
|
||||
);
|
||||
|
||||
const rawIngredients = database
|
||||
.prepare("SELECT * FROM ingredients ORDER BY id")
|
||||
.all() as unknown as Array<{
|
||||
id: string;
|
||||
schema_version: number;
|
||||
name: string;
|
||||
status: string;
|
||||
categories_json: string;
|
||||
tags_json: string;
|
||||
description: string | null;
|
||||
source_json: string;
|
||||
deleted_at: string | null;
|
||||
}>;
|
||||
|
||||
const ingredients: IngredientBackupRecord[] = rawIngredients.map((ing) => ({
|
||||
...ing,
|
||||
aliases: aliasStmt.all(ing.id) as any[],
|
||||
prep_actions: ingredientPrepStmt.all(ing.id) as any[],
|
||||
measure_conversions: ingredientConvStmt.all(ing.id) as any[],
|
||||
density_measurements: ingredientDensityStmt.all(ing.id) as any[],
|
||||
}));
|
||||
|
||||
// 5. Recipes & sub-tables
|
||||
const componentStmt = database.prepare(
|
||||
"SELECT id, position, name, notes_json FROM recipe_components WHERE recipe_id = ? ORDER BY position"
|
||||
);
|
||||
const itemStmt = database.prepare(
|
||||
"SELECT component_id, id, position, ingredient_id, subrecipe_id, quantity, unit_id, percentage, basis_member, optional, notes, nutrition_retention_factor FROM recipe_items WHERE recipe_id = ? ORDER BY component_id, position"
|
||||
);
|
||||
const itemPrepStmt = database.prepare(
|
||||
"SELECT position, action_id, yield_factor, notes FROM item_prep_actions WHERE recipe_id = ? AND item_id = ? ORDER BY position"
|
||||
);
|
||||
const stepStmt = database.prepare(
|
||||
"SELECT id, position, instruction, critical_control_point FROM recipe_steps WHERE recipe_id = ? ORDER BY position"
|
||||
);
|
||||
const stepEquipmentStmt = database.prepare(
|
||||
"SELECT equipment_id FROM step_equipment WHERE recipe_id = ? AND step_id = ? ORDER BY equipment_id"
|
||||
);
|
||||
const recipeEquipmentStmt = database.prepare(
|
||||
"SELECT equipment_id FROM recipe_equipment WHERE recipe_id = ? ORDER BY equipment_id"
|
||||
);
|
||||
const recipeConvStmt = database.prepare(
|
||||
"SELECT id, from_quantity, from_unit_id, to_quantity, to_unit_id, notes, source_json FROM recipe_measure_conversions WHERE recipe_id = ? ORDER BY id"
|
||||
);
|
||||
const recipeMediaStmt = database.prepare(
|
||||
"SELECT id, step_id, media_type, url, caption, position FROM recipe_media WHERE recipe_id = ? ORDER BY position"
|
||||
);
|
||||
|
||||
const rawRecipes = database
|
||||
.prepare("SELECT * FROM recipes ORDER BY id")
|
||||
.all() as unknown as Array<{
|
||||
id: string;
|
||||
schema_version: number;
|
||||
save_version: number;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
categories_json: string;
|
||||
tags_json: string;
|
||||
yield_quantity: number;
|
||||
yield_unit_id: string;
|
||||
yield_servings: number | null;
|
||||
yield_basis: string | null;
|
||||
scaling_mode: string | null;
|
||||
scaling_basis_id: string | null;
|
||||
scaling_basis_quantity: number | null;
|
||||
scaling_basis_unit_id: string | null;
|
||||
notes_json: string;
|
||||
source_json: string;
|
||||
auto_yield: number;
|
||||
station: string | null;
|
||||
deleted_at: string | null;
|
||||
cover_media_url: string | null;
|
||||
}>;
|
||||
|
||||
const recipes: RecipeBackupRecord[] = rawRecipes.map((rec) => {
|
||||
const rawItems = itemStmt.all(rec.id) as any[];
|
||||
const items = rawItems.map((item) => ({
|
||||
...item,
|
||||
prep_actions: itemPrepStmt.all(rec.id, item.id) as any[],
|
||||
}));
|
||||
|
||||
const rawSteps = stepStmt.all(rec.id) as any[];
|
||||
const steps = rawSteps.map((step) => ({
|
||||
...step,
|
||||
equipment_ids: (stepEquipmentStmt.all(rec.id, step.id) as any[]).map(
|
||||
(r) => r.equipment_id
|
||||
),
|
||||
}));
|
||||
|
||||
const equipment_ids = (recipeEquipmentStmt.all(rec.id) as any[]).map(
|
||||
(r) => r.equipment_id
|
||||
);
|
||||
|
||||
return {
|
||||
...rec,
|
||||
equipment_ids,
|
||||
components: componentStmt.all(rec.id) as any[],
|
||||
items,
|
||||
steps,
|
||||
measure_conversions: recipeConvStmt.all(rec.id) as any[],
|
||||
media: recipeMediaStmt.all(rec.id) as any[],
|
||||
};
|
||||
});
|
||||
|
||||
// 6. Purchase items & price observations
|
||||
const priceStmt = database.prepare(
|
||||
"SELECT effective_at, currency, amount, source_json FROM price_observations WHERE purchase_item_id = ? ORDER BY effective_at DESC"
|
||||
);
|
||||
const rawPurchaseItems = database
|
||||
.prepare("SELECT * FROM purchase_items ORDER BY id")
|
||||
.all() as unknown as Array<{
|
||||
id: string;
|
||||
ingredient_id: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
supplier_id: string | null;
|
||||
supplier_sku: string | null;
|
||||
status: string;
|
||||
package_quantity: number;
|
||||
package_unit_id: string;
|
||||
units_per_case: number;
|
||||
usable_yield_factor: number;
|
||||
deleted_at: string | null;
|
||||
}>;
|
||||
|
||||
let totalPricesCount = 0;
|
||||
const purchase_items: PurchaseItemBackupRecord[] = rawPurchaseItems.map(
|
||||
(pi) => {
|
||||
const prices = priceStmt.all(pi.id) as any[];
|
||||
totalPricesCount += prices.length;
|
||||
return {
|
||||
...pi,
|
||||
prices,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// 7. Source mappings
|
||||
const source_mappings = database
|
||||
.prepare("SELECT * FROM source_mappings ORDER BY id")
|
||||
.all() as unknown as SourceMappingBackupRecord[];
|
||||
|
||||
// 8. Collections & collection recipes
|
||||
const collRecipeStmt = database.prepare(
|
||||
"SELECT recipe_id, position FROM collection_recipes WHERE collection_id = ? ORDER BY position"
|
||||
);
|
||||
const rawCollections = database
|
||||
.prepare("SELECT * FROM collections ORDER BY id")
|
||||
.all() as unknown as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
source_json: string;
|
||||
deleted_at: string | null;
|
||||
}>;
|
||||
|
||||
const collections: CollectionBackupRecord[] = rawCollections.map((coll) => ({
|
||||
...coll,
|
||||
recipes: collRecipeStmt.all(coll.id) as any[],
|
||||
}));
|
||||
|
||||
// 9. Inventory locations
|
||||
let inventory_locations: InventoryLocationBackupRecord[] = [];
|
||||
try {
|
||||
inventory_locations = database
|
||||
.prepare("SELECT id, name, position, deleted_at FROM inventory_locations ORDER BY position, id")
|
||||
.all() as unknown as InventoryLocationBackupRecord[];
|
||||
} catch {}
|
||||
|
||||
// 10. Inventory counts & count items
|
||||
let inventory_counts: InventoryCountBackupRecord[] = [];
|
||||
try {
|
||||
const countItemStmt = database.prepare(
|
||||
"SELECT location_id, ingredient_id, quantity, unit_id, unit_cost, extended_cost FROM inventory_count_items WHERE count_id = ? ORDER BY location_id, ingredient_id"
|
||||
);
|
||||
const rawCounts = database
|
||||
.prepare("SELECT id, title, counted_at, status, notes, created_at, deleted_at FROM inventory_counts ORDER BY counted_at DESC, id")
|
||||
.all() as unknown as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
counted_at: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
}>;
|
||||
|
||||
inventory_counts = rawCounts.map((count) => ({
|
||||
...count,
|
||||
items: countItemStmt.all(count.id) as any[],
|
||||
}));
|
||||
} catch {}
|
||||
|
||||
const summary: BackupSummary = {
|
||||
units_count: units.length,
|
||||
equipment_count: equipment.length,
|
||||
prep_actions_count: prepActions.length,
|
||||
ingredients_count: ingredients.length,
|
||||
recipes_count: recipes.length,
|
||||
purchase_items_count: purchase_items.length,
|
||||
price_observations_count: totalPricesCount,
|
||||
source_mappings_count: source_mappings.length,
|
||||
collections_count: collections.length,
|
||||
inventory_locations_count: inventory_locations.length,
|
||||
inventory_counts_count: inventory_counts.length,
|
||||
total_records_count:
|
||||
units.length +
|
||||
equipment.length +
|
||||
prepActions.length +
|
||||
ingredients.length +
|
||||
recipes.length +
|
||||
purchase_items.length +
|
||||
source_mappings.length +
|
||||
collections.length +
|
||||
inventory_locations.length +
|
||||
inventory_counts.length,
|
||||
};
|
||||
|
||||
return {
|
||||
$schema: "https://formulation.app/schemas/backup-v1.json",
|
||||
format_version: "1.0.0",
|
||||
app_version: "2.0.0",
|
||||
exported_at: new Date().toISOString(),
|
||||
summary,
|
||||
data: {
|
||||
units,
|
||||
equipment,
|
||||
prep_actions: prepActions,
|
||||
ingredients,
|
||||
recipes,
|
||||
purchase_items,
|
||||
source_mappings,
|
||||
collections,
|
||||
inventory_locations,
|
||||
inventory_counts,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { validateBackupBundle } from "./validate-backup.ts";
|
||||
import { writeSiteProjection } from "../../../scripts/lib/site-projection.mjs";
|
||||
import type {
|
||||
FormulationBackupBundle,
|
||||
ImportOptions,
|
||||
ImportResult,
|
||||
} from "./types.ts";
|
||||
|
||||
export function importDatabase(
|
||||
database: DatabaseSync,
|
||||
bundle: FormulationBackupBundle,
|
||||
options: ImportOptions = {}
|
||||
): ImportResult {
|
||||
const mode = options.mode ?? "replace";
|
||||
const validation = validateBackupBundle(bundle);
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new Error(
|
||||
`Backup validation failed: ${validation.errors.join("; ")}`
|
||||
);
|
||||
}
|
||||
|
||||
const { data, summary } = bundle;
|
||||
|
||||
database.exec("BEGIN IMMEDIATE;");
|
||||
try {
|
||||
database.exec("PRAGMA defer_foreign_keys = ON;");
|
||||
|
||||
if (mode === "replace") {
|
||||
// Clear tables in reverse dependency order
|
||||
const tables = [
|
||||
"inventory_count_items",
|
||||
"inventory_counts",
|
||||
"inventory_locations",
|
||||
"collection_recipes",
|
||||
"collections",
|
||||
"source_mappings",
|
||||
"price_observations",
|
||||
"purchase_items",
|
||||
"recipe_media",
|
||||
"recipe_measure_conversions",
|
||||
"step_equipment",
|
||||
"recipe_equipment",
|
||||
"recipe_steps",
|
||||
"item_prep_actions",
|
||||
"recipe_items",
|
||||
"recipe_components",
|
||||
"recipes",
|
||||
"ingredient_density_measurements",
|
||||
"ingredient_measure_conversions",
|
||||
"ingredient_prep_actions",
|
||||
"ingredient_aliases",
|
||||
"ingredients",
|
||||
"prep_actions",
|
||||
"equipment",
|
||||
"units",
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
database.exec(`DELETE FROM ${table};`);
|
||||
} catch {
|
||||
// Table might not exist in old migration environments
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Units
|
||||
const unitStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO units(id, name, symbol, dimension, system, base_unit_id, factor, offset) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
for (const u of data.units ?? []) {
|
||||
unitStmt.run(
|
||||
u.id,
|
||||
u.name,
|
||||
u.symbol,
|
||||
u.dimension,
|
||||
u.system,
|
||||
u.base_unit_id ?? null,
|
||||
u.factor ?? null,
|
||||
u.offset ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Equipment
|
||||
const eqStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO equipment(id, name, category, notes) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
for (const eq of data.equipment ?? []) {
|
||||
eqStmt.run(eq.id, eq.name, eq.category ?? null, eq.notes ?? null);
|
||||
}
|
||||
|
||||
// 3. Prep Actions
|
||||
const prepStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO prep_actions(id, name, action_type, default_yield_factor, notes) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
for (const pa of data.prep_actions ?? []) {
|
||||
prepStmt.run(
|
||||
pa.id,
|
||||
pa.name,
|
||||
pa.action_type,
|
||||
pa.default_yield_factor ?? null,
|
||||
pa.notes ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Ingredients & sub-tables
|
||||
const ingStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO ingredients(id, schema_version, name, status, categories_json, tags_json, description, source_json, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
const aliasStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO ingredient_aliases(ingredient_id, name, kind) VALUES (?, ?, ?)"
|
||||
);
|
||||
const ingPrepStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO ingredient_prep_actions(ingredient_id, action_id, yield_factor, notes) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
const ingConvStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO ingredient_measure_conversions(ingredient_id, id, from_quantity, from_unit_id, to_quantity, to_unit_id, state, source_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
const ingDensityStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO ingredient_density_measurements(ingredient_id, id, mass_quantity, mass_unit_id, volume_quantity, volume_unit_id, temperature_c, state, source_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
for (const ing of data.ingredients ?? []) {
|
||||
ingStmt.run(
|
||||
ing.id,
|
||||
ing.schema_version ?? 2,
|
||||
ing.name,
|
||||
ing.status ?? "active",
|
||||
ing.categories_json ?? "[]",
|
||||
ing.tags_json ?? "[]",
|
||||
ing.description ?? null,
|
||||
ing.source_json ?? "{}",
|
||||
ing.deleted_at ?? null
|
||||
);
|
||||
|
||||
if (mode === "merge") {
|
||||
database.prepare("DELETE FROM ingredient_aliases WHERE ingredient_id = ?").run(ing.id);
|
||||
database.prepare("DELETE FROM ingredient_prep_actions WHERE ingredient_id = ?").run(ing.id);
|
||||
database.prepare("DELETE FROM ingredient_measure_conversions WHERE ingredient_id = ?").run(ing.id);
|
||||
database.prepare("DELETE FROM ingredient_density_measurements WHERE ingredient_id = ?").run(ing.id);
|
||||
}
|
||||
|
||||
for (const alias of ing.aliases ?? []) {
|
||||
aliasStmt.run(ing.id, alias.name, alias.kind ?? null);
|
||||
}
|
||||
for (const pa of ing.prep_actions ?? []) {
|
||||
ingPrepStmt.run(ing.id, pa.action_id, pa.yield_factor, pa.notes ?? null);
|
||||
}
|
||||
for (const conv of ing.measure_conversions ?? []) {
|
||||
ingConvStmt.run(
|
||||
ing.id,
|
||||
conv.id,
|
||||
conv.from_quantity,
|
||||
conv.from_unit_id,
|
||||
conv.to_quantity,
|
||||
conv.to_unit_id,
|
||||
conv.state ?? null,
|
||||
conv.source_json ?? "{}"
|
||||
);
|
||||
}
|
||||
for (const density of ing.density_measurements ?? []) {
|
||||
ingDensityStmt.run(
|
||||
ing.id,
|
||||
density.id,
|
||||
density.mass_quantity,
|
||||
density.mass_unit_id,
|
||||
density.volume_quantity,
|
||||
density.volume_unit_id,
|
||||
density.temperature_c ?? null,
|
||||
density.state ?? null,
|
||||
density.source_json ?? "{}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Recipes & sub-tables
|
||||
const recStmt = database.prepare(
|
||||
`INSERT OR REPLACE INTO recipes(
|
||||
id, schema_version, save_version, title, summary,
|
||||
categories_json, tags_json, yield_quantity, yield_unit_id,
|
||||
yield_servings, yield_basis, scaling_mode, scaling_basis_id,
|
||||
scaling_basis_quantity, scaling_basis_unit_id, notes_json,
|
||||
source_json, auto_yield, station, deleted_at, cover_media_url
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const recEqStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO recipe_equipment(recipe_id, equipment_id) VALUES (?, ?)"
|
||||
);
|
||||
const compStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO recipe_components(recipe_id, id, position, name, notes_json) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
const itemStmt = database.prepare(
|
||||
`INSERT OR REPLACE INTO recipe_items(
|
||||
recipe_id, component_id, id, position, ingredient_id,
|
||||
subrecipe_id, quantity, unit_id, percentage, basis_member,
|
||||
optional, notes, nutrition_retention_factor
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const itemPrepStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO item_prep_actions(recipe_id, item_id, position, action_id, yield_factor, notes) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
const stepStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO recipe_steps(recipe_id, id, position, instruction, critical_control_point) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
const stepEqStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO step_equipment(recipe_id, step_id, equipment_id) VALUES (?, ?, ?)"
|
||||
);
|
||||
const recConvStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO recipe_measure_conversions(recipe_id, id, from_quantity, from_unit_id, to_quantity, to_unit_id, notes, source_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
const mediaStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO recipe_media(recipe_id, id, step_id, media_type, url, caption, position) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
for (const rec of data.recipes ?? []) {
|
||||
recStmt.run(
|
||||
rec.id,
|
||||
rec.schema_version ?? 2,
|
||||
rec.save_version ?? 1,
|
||||
rec.title,
|
||||
rec.summary ?? null,
|
||||
rec.categories_json ?? "[]",
|
||||
rec.tags_json ?? "[]",
|
||||
rec.yield_quantity,
|
||||
rec.yield_unit_id,
|
||||
rec.yield_servings ?? null,
|
||||
rec.yield_basis ?? null,
|
||||
rec.scaling_mode ?? null,
|
||||
rec.scaling_basis_id ?? null,
|
||||
rec.scaling_basis_quantity ?? null,
|
||||
rec.scaling_basis_unit_id ?? null,
|
||||
rec.notes_json ?? "[]",
|
||||
rec.source_json ?? "{}",
|
||||
rec.auto_yield ?? 0,
|
||||
rec.station ?? null,
|
||||
rec.deleted_at ?? null,
|
||||
rec.cover_media_url ?? null
|
||||
);
|
||||
|
||||
if (mode === "merge") {
|
||||
database.prepare("DELETE FROM recipe_equipment WHERE recipe_id = ?").run(rec.id);
|
||||
database.prepare("DELETE FROM recipe_components WHERE recipe_id = ?").run(rec.id);
|
||||
database.prepare("DELETE FROM recipe_items WHERE recipe_id = ?").run(rec.id);
|
||||
database.prepare("DELETE FROM recipe_steps WHERE recipe_id = ?").run(rec.id);
|
||||
database.prepare("DELETE FROM recipe_measure_conversions WHERE recipe_id = ?").run(rec.id);
|
||||
database.prepare("DELETE FROM recipe_media WHERE recipe_id = ?").run(rec.id);
|
||||
}
|
||||
|
||||
for (const eqId of rec.equipment_ids ?? []) {
|
||||
recEqStmt.run(rec.id, eqId);
|
||||
}
|
||||
|
||||
for (const comp of rec.components ?? []) {
|
||||
compStmt.run(rec.id, comp.id, comp.position, comp.name, comp.notes_json ?? "[]");
|
||||
}
|
||||
|
||||
for (const item of rec.items ?? []) {
|
||||
itemStmt.run(
|
||||
rec.id,
|
||||
item.component_id,
|
||||
item.id,
|
||||
item.position,
|
||||
item.ingredient_id ?? null,
|
||||
item.subrecipe_id ?? null,
|
||||
item.quantity,
|
||||
item.unit_id,
|
||||
item.percentage ?? null,
|
||||
item.basis_member ?? 0,
|
||||
item.optional ?? 0,
|
||||
item.notes ?? null,
|
||||
item.nutrition_retention_factor ?? 1
|
||||
);
|
||||
|
||||
for (const ipa of item.prep_actions ?? []) {
|
||||
itemPrepStmt.run(
|
||||
rec.id,
|
||||
item.id,
|
||||
ipa.position,
|
||||
ipa.action_id,
|
||||
ipa.yield_factor ?? null,
|
||||
ipa.notes ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const step of rec.steps ?? []) {
|
||||
stepStmt.run(
|
||||
rec.id,
|
||||
step.id,
|
||||
step.position,
|
||||
step.instruction,
|
||||
step.critical_control_point ?? 0
|
||||
);
|
||||
|
||||
for (const eqId of step.equipment_ids ?? []) {
|
||||
stepEqStmt.run(rec.id, step.id, eqId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const conv of rec.measure_conversions ?? []) {
|
||||
recConvStmt.run(
|
||||
rec.id,
|
||||
conv.id,
|
||||
conv.from_quantity,
|
||||
conv.from_unit_id,
|
||||
conv.to_quantity,
|
||||
conv.to_unit_id,
|
||||
conv.notes ?? null,
|
||||
conv.source_json ?? "{}"
|
||||
);
|
||||
}
|
||||
|
||||
for (const m of rec.media ?? []) {
|
||||
mediaStmt.run(
|
||||
rec.id,
|
||||
m.id,
|
||||
m.step_id ?? null,
|
||||
m.media_type,
|
||||
m.url,
|
||||
m.caption ?? null,
|
||||
m.position
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Purchase items & prices
|
||||
const piStmt = database.prepare(
|
||||
`INSERT OR REPLACE INTO purchase_items(
|
||||
id, ingredient_id, name, brand, supplier_id,
|
||||
supplier_sku, status, package_quantity, package_unit_id,
|
||||
units_per_case, usable_yield_factor, deleted_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const priceStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO price_observations(purchase_item_id, effective_at, currency, amount, source_json) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
for (const pi of data.purchase_items ?? []) {
|
||||
piStmt.run(
|
||||
pi.id,
|
||||
pi.ingredient_id,
|
||||
pi.name,
|
||||
pi.brand ?? null,
|
||||
pi.supplier_id ?? null,
|
||||
pi.supplier_sku ?? null,
|
||||
pi.status ?? "active",
|
||||
pi.package_quantity,
|
||||
pi.package_unit_id,
|
||||
pi.units_per_case ?? 1,
|
||||
pi.usable_yield_factor ?? 1,
|
||||
pi.deleted_at ?? null
|
||||
);
|
||||
|
||||
if (mode === "merge") {
|
||||
database.prepare("DELETE FROM price_observations WHERE purchase_item_id = ?").run(pi.id);
|
||||
}
|
||||
|
||||
for (const price of pi.prices ?? []) {
|
||||
priceStmt.run(
|
||||
pi.id,
|
||||
price.effective_at,
|
||||
price.currency,
|
||||
price.amount,
|
||||
price.source_json ?? "{}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Source mappings
|
||||
const smStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO source_mappings(id, subject_type, subject_id, mapping_type, status, source_json, nutrition_json) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
for (const sm of data.source_mappings ?? []) {
|
||||
smStmt.run(
|
||||
sm.id,
|
||||
sm.subject_type,
|
||||
sm.subject_id,
|
||||
sm.mapping_type,
|
||||
sm.status,
|
||||
sm.source_json ?? "{}",
|
||||
sm.nutrition_json ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// 8. Collections & collection recipes
|
||||
const collStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO collections(id, name, description, source_json, deleted_at) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
const collRecStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO collection_recipes(collection_id, recipe_id, position) VALUES (?, ?, ?)"
|
||||
);
|
||||
|
||||
for (const coll of data.collections ?? []) {
|
||||
collStmt.run(
|
||||
coll.id,
|
||||
coll.name,
|
||||
coll.description ?? null,
|
||||
coll.source_json ?? "{}",
|
||||
coll.deleted_at ?? null
|
||||
);
|
||||
|
||||
if (mode === "merge") {
|
||||
database.prepare("DELETE FROM collection_recipes WHERE collection_id = ?").run(coll.id);
|
||||
}
|
||||
|
||||
for (const cr of coll.recipes ?? []) {
|
||||
collRecStmt.run(coll.id, cr.recipe_id, cr.position);
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Inventory locations
|
||||
if (Array.isArray(data.inventory_locations) && data.inventory_locations.length > 0) {
|
||||
const locStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO inventory_locations(id, name, position, deleted_at) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
for (const loc of data.inventory_locations) {
|
||||
locStmt.run(loc.id, loc.name, loc.position, loc.deleted_at ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Inventory counts & count items
|
||||
if (Array.isArray(data.inventory_counts) && data.inventory_counts.length > 0) {
|
||||
const countStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO inventory_counts(id, title, counted_at, status, notes, created_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
const countItemStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO inventory_count_items(count_id, location_id, ingredient_id, quantity, unit_id, unit_cost, extended_cost) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
for (const count of data.inventory_counts) {
|
||||
countStmt.run(
|
||||
count.id,
|
||||
count.title,
|
||||
count.counted_at,
|
||||
count.status ?? "open",
|
||||
count.notes ?? null,
|
||||
count.created_at,
|
||||
count.deleted_at ?? null
|
||||
);
|
||||
|
||||
if (mode === "merge") {
|
||||
database.prepare("DELETE FROM inventory_count_items WHERE count_id = ?").run(count.id);
|
||||
}
|
||||
|
||||
for (const item of count.items ?? []) {
|
||||
countItemStmt.run(
|
||||
count.id,
|
||||
item.location_id ?? null,
|
||||
item.ingredient_id,
|
||||
item.quantity,
|
||||
item.unit_id,
|
||||
item.unit_cost ?? null,
|
||||
item.extended_cost ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
database.exec("COMMIT;");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK;");
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (options.rebuildProjections !== false) {
|
||||
try {
|
||||
writeSiteProjection(database);
|
||||
} catch (err) {
|
||||
console.warn("Failed to rebuild site projection after import:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
mode,
|
||||
imported_at: new Date().toISOString(),
|
||||
summary,
|
||||
message: `Successfully imported ${summary.total_records_count} records (${summary.recipes_count} recipes, ${summary.ingredients_count} ingredients) in '${mode}' mode.`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./types.ts";
|
||||
export * from "./export-database.ts";
|
||||
export * from "./import-database.ts";
|
||||
export * from "./validate-backup.ts";
|
||||
@@ -0,0 +1,297 @@
|
||||
export interface BackupMetadata {
|
||||
$schema: "https://formulation.app/schemas/backup-v1.json";
|
||||
format_version: "1.0.0";
|
||||
app_version: string;
|
||||
exported_at: string;
|
||||
database_version?: number;
|
||||
}
|
||||
|
||||
export interface BackupSummary {
|
||||
units_count: number;
|
||||
equipment_count: number;
|
||||
prep_actions_count: number;
|
||||
ingredients_count: number;
|
||||
recipes_count: number;
|
||||
purchase_items_count: number;
|
||||
price_observations_count: number;
|
||||
source_mappings_count: number;
|
||||
collections_count: number;
|
||||
inventory_locations_count: number;
|
||||
inventory_counts_count: number;
|
||||
total_records_count: number;
|
||||
}
|
||||
|
||||
export interface UnitBackupRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
dimension: string;
|
||||
system: string;
|
||||
base_unit_id: string | null;
|
||||
factor: number | null;
|
||||
offset: number | null;
|
||||
}
|
||||
|
||||
export interface EquipmentBackupRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface PrepActionBackupRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
action_type: string;
|
||||
default_yield_factor: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface IngredientAliasBackupRecord {
|
||||
name: string;
|
||||
kind: string | null;
|
||||
}
|
||||
|
||||
export interface IngredientPrepActionBackupRecord {
|
||||
action_id: string;
|
||||
yield_factor: number;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface IngredientMeasureConversionBackupRecord {
|
||||
id: string;
|
||||
from_quantity: number;
|
||||
from_unit_id: string;
|
||||
to_quantity: number;
|
||||
to_unit_id: string;
|
||||
state: string | null;
|
||||
source_json: string;
|
||||
}
|
||||
|
||||
export interface IngredientDensityMeasurementBackupRecord {
|
||||
id: string;
|
||||
mass_quantity: number;
|
||||
mass_unit_id: string;
|
||||
volume_quantity: number;
|
||||
volume_unit_id: string;
|
||||
temperature_c: number | null;
|
||||
state: string | null;
|
||||
source_json: string;
|
||||
}
|
||||
|
||||
export interface IngredientBackupRecord {
|
||||
id: string;
|
||||
schema_version: number;
|
||||
name: string;
|
||||
status: string;
|
||||
categories_json: string;
|
||||
tags_json: string;
|
||||
description: string | null;
|
||||
source_json: string;
|
||||
deleted_at: string | null;
|
||||
aliases: IngredientAliasBackupRecord[];
|
||||
prep_actions: IngredientPrepActionBackupRecord[];
|
||||
measure_conversions: IngredientMeasureConversionBackupRecord[];
|
||||
density_measurements: IngredientDensityMeasurementBackupRecord[];
|
||||
}
|
||||
|
||||
export interface ItemPrepActionBackupRecord {
|
||||
position: number;
|
||||
action_id: string;
|
||||
yield_factor: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface RecipeItemBackupRecord {
|
||||
component_id: string;
|
||||
id: string;
|
||||
position: number;
|
||||
ingredient_id: string | null;
|
||||
subrecipe_id: string | null;
|
||||
quantity: number;
|
||||
unit_id: string;
|
||||
percentage: number | null;
|
||||
basis_member: number;
|
||||
optional: number;
|
||||
notes: string | null;
|
||||
nutrition_retention_factor: number;
|
||||
prep_actions: ItemPrepActionBackupRecord[];
|
||||
}
|
||||
|
||||
export interface RecipeComponentBackupRecord {
|
||||
id: string;
|
||||
position: number;
|
||||
name: string;
|
||||
notes_json: string;
|
||||
}
|
||||
|
||||
export interface RecipeStepBackupRecord {
|
||||
id: string;
|
||||
position: number;
|
||||
instruction: string;
|
||||
critical_control_point: number;
|
||||
equipment_ids: string[];
|
||||
}
|
||||
|
||||
export interface RecipeMeasureConversionBackupRecord {
|
||||
id: string;
|
||||
from_quantity: number;
|
||||
from_unit_id: string;
|
||||
to_quantity: number;
|
||||
to_unit_id: string;
|
||||
notes: string | null;
|
||||
source_json: string;
|
||||
}
|
||||
|
||||
export interface RecipeMediaBackupRecord {
|
||||
id: string;
|
||||
step_id: string | null;
|
||||
media_type: string;
|
||||
url: string;
|
||||
caption: string | null;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface RecipeBackupRecord {
|
||||
id: string;
|
||||
schema_version: number;
|
||||
save_version: number;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
categories_json: string;
|
||||
tags_json: string;
|
||||
yield_quantity: number;
|
||||
yield_unit_id: string;
|
||||
yield_servings: number | null;
|
||||
yield_basis: string | null;
|
||||
scaling_mode: string | null;
|
||||
scaling_basis_id: string | null;
|
||||
scaling_basis_quantity: number | null;
|
||||
scaling_basis_unit_id: string | null;
|
||||
notes_json: string;
|
||||
source_json: string;
|
||||
auto_yield: number;
|
||||
station: string | null;
|
||||
deleted_at: string | null;
|
||||
cover_media_url: string | null;
|
||||
equipment_ids: string[];
|
||||
components: RecipeComponentBackupRecord[];
|
||||
items: RecipeItemBackupRecord[];
|
||||
steps: RecipeStepBackupRecord[];
|
||||
measure_conversions: RecipeMeasureConversionBackupRecord[];
|
||||
media: RecipeMediaBackupRecord[];
|
||||
}
|
||||
|
||||
export interface PriceObservationBackupRecord {
|
||||
effective_at: string;
|
||||
currency: string;
|
||||
amount: number;
|
||||
source_json: string;
|
||||
}
|
||||
|
||||
export interface PurchaseItemBackupRecord {
|
||||
id: string;
|
||||
ingredient_id: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
supplier_id: string | null;
|
||||
supplier_sku: string | null;
|
||||
status: string;
|
||||
package_quantity: number;
|
||||
package_unit_id: string;
|
||||
units_per_case: number;
|
||||
usable_yield_factor: number;
|
||||
deleted_at: string | null;
|
||||
prices: PriceObservationBackupRecord[];
|
||||
}
|
||||
|
||||
export interface SourceMappingBackupRecord {
|
||||
id: string;
|
||||
subject_type: string;
|
||||
subject_id: string;
|
||||
mapping_type: string;
|
||||
status: string;
|
||||
source_json: string;
|
||||
nutrition_json: string | null;
|
||||
}
|
||||
|
||||
export interface CollectionRecipeBackupRecord {
|
||||
recipe_id: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface CollectionBackupRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
source_json: string;
|
||||
deleted_at: string | null;
|
||||
recipes: CollectionRecipeBackupRecord[];
|
||||
}
|
||||
|
||||
export interface InventoryLocationBackupRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
export interface InventoryCountItemBackupRecord {
|
||||
location_id: string | null;
|
||||
ingredient_id: string;
|
||||
quantity: number;
|
||||
unit_id: string;
|
||||
unit_cost: number | null;
|
||||
extended_cost: number | null;
|
||||
}
|
||||
|
||||
export interface InventoryCountBackupRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
counted_at: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
items: InventoryCountItemBackupRecord[];
|
||||
}
|
||||
|
||||
export interface FormulationBackupData {
|
||||
units: UnitBackupRecord[];
|
||||
equipment: EquipmentBackupRecord[];
|
||||
prep_actions: PrepActionBackupRecord[];
|
||||
ingredients: IngredientBackupRecord[];
|
||||
recipes: RecipeBackupRecord[];
|
||||
purchase_items: PurchaseItemBackupRecord[];
|
||||
source_mappings: SourceMappingBackupRecord[];
|
||||
collections: CollectionBackupRecord[];
|
||||
inventory_locations: InventoryLocationBackupRecord[];
|
||||
inventory_counts: InventoryCountBackupRecord[];
|
||||
}
|
||||
|
||||
export interface FormulationBackupBundle extends BackupMetadata {
|
||||
summary: BackupSummary;
|
||||
data: FormulationBackupData;
|
||||
}
|
||||
|
||||
export type ImportMode = "replace" | "merge";
|
||||
|
||||
export interface ImportOptions {
|
||||
mode?: ImportMode;
|
||||
rebuildProjections?: boolean;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
success: boolean;
|
||||
mode: ImportMode;
|
||||
imported_at: string;
|
||||
summary: BackupSummary;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
summary?: BackupSummary;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { FormulationBackupBundle, ValidationResult, BackupSummary } from "./types";
|
||||
|
||||
export function validateBackupBundle(input: unknown): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!input || typeof input !== "object") {
|
||||
return {
|
||||
valid: false,
|
||||
errors: ["Invalid backup payload: expected a JSON object."],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const bundle = input as Partial<FormulationBackupBundle>;
|
||||
|
||||
if (!bundle.format_version) {
|
||||
errors.push("Missing 'format_version' in backup metadata.");
|
||||
} else if (!bundle.format_version.startsWith("1.")) {
|
||||
errors.push(`Unsupported backup format_version: '${bundle.format_version}'. Expected 1.x.`);
|
||||
}
|
||||
|
||||
if (!bundle.data || typeof bundle.data !== "object") {
|
||||
errors.push("Missing 'data' container in backup payload.");
|
||||
return { valid: false, errors, warnings };
|
||||
}
|
||||
|
||||
const { data } = bundle;
|
||||
|
||||
if (!Array.isArray(data.units)) {
|
||||
errors.push("Missing 'data.units' array in backup payload.");
|
||||
}
|
||||
if (!Array.isArray(data.ingredients)) {
|
||||
errors.push("Missing 'data.ingredients' array in backup payload.");
|
||||
}
|
||||
if (!Array.isArray(data.recipes)) {
|
||||
errors.push("Missing 'data.recipes' array in backup payload.");
|
||||
}
|
||||
|
||||
// Check unit integrity
|
||||
const unitIds = new Set<string>();
|
||||
if (Array.isArray(data.units)) {
|
||||
for (const unit of data.units) {
|
||||
if (!unit.id || !unit.name || !unit.dimension || !unit.system) {
|
||||
errors.push(`Unit entry is missing required fields: ${JSON.stringify(unit)}`);
|
||||
break;
|
||||
}
|
||||
unitIds.add(unit.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Check ingredient integrity
|
||||
const ingredientIds = new Set<string>();
|
||||
if (Array.isArray(data.ingredients)) {
|
||||
for (const ingredient of data.ingredients) {
|
||||
if (!ingredient.id || !ingredient.name) {
|
||||
errors.push(`Ingredient entry is missing required fields (id, name): ${JSON.stringify(ingredient)}`);
|
||||
break;
|
||||
}
|
||||
ingredientIds.add(ingredient.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Check recipe integrity
|
||||
if (Array.isArray(data.recipes)) {
|
||||
for (const recipe of data.recipes) {
|
||||
if (!recipe.id || !recipe.title || !recipe.yield_unit_id) {
|
||||
errors.push(`Recipe entry is missing required fields (id, title, yield_unit_id): ${JSON.stringify(recipe)}`);
|
||||
break;
|
||||
}
|
||||
if (unitIds.size > 0 && !unitIds.has(recipe.yield_unit_id)) {
|
||||
warnings.push(`Recipe '${recipe.title}' (${recipe.id}) references unknown yield unit '${recipe.yield_unit_id}'.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary: BackupSummary = bundle.summary ?? {
|
||||
units_count: Array.isArray(data.units) ? data.units.length : 0,
|
||||
equipment_count: Array.isArray(data.equipment) ? data.equipment.length : 0,
|
||||
prep_actions_count: Array.isArray(data.prep_actions) ? data.prep_actions.length : 0,
|
||||
ingredients_count: Array.isArray(data.ingredients) ? data.ingredients.length : 0,
|
||||
recipes_count: Array.isArray(data.recipes) ? data.recipes.length : 0,
|
||||
purchase_items_count: Array.isArray(data.purchase_items) ? data.purchase_items.length : 0,
|
||||
price_observations_count: Array.isArray(data.purchase_items)
|
||||
? data.purchase_items.reduce((acc, pi) => acc + (pi.prices?.length ?? 0), 0)
|
||||
: 0,
|
||||
source_mappings_count: Array.isArray(data.source_mappings) ? data.source_mappings.length : 0,
|
||||
collections_count: Array.isArray(data.collections) ? data.collections.length : 0,
|
||||
inventory_locations_count: Array.isArray(data.inventory_locations) ? data.inventory_locations.length : 0,
|
||||
inventory_counts_count: Array.isArray(data.inventory_counts) ? data.inventory_counts.length : 0,
|
||||
total_records_count:
|
||||
(data.units?.length ?? 0) +
|
||||
(data.equipment?.length ?? 0) +
|
||||
(data.prep_actions?.length ?? 0) +
|
||||
(data.ingredients?.length ?? 0) +
|
||||
(data.recipes?.length ?? 0) +
|
||||
(data.purchase_items?.length ?? 0) +
|
||||
(data.source_mappings?.length ?? 0) +
|
||||
(data.collections?.length ?? 0) +
|
||||
(data.inventory_locations?.length ?? 0) +
|
||||
(data.inventory_counts?.length ?? 0),
|
||||
};
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,13 @@ describe("recipe costing", () => {
|
||||
expect(result.perServing).toBeCloseTo(0.125);
|
||||
expect(result.per100g).toBeCloseTo(0.25);
|
||||
expect(result.completeness).toBe(1);
|
||||
expect(result.lines).toMatchObject([{
|
||||
subjectId: "flour",
|
||||
kind: "ingredient",
|
||||
cost: 0.5,
|
||||
completeness: 1,
|
||||
purchase: { id: "flour_bag", price: 5, currency: "USD" },
|
||||
}]);
|
||||
});
|
||||
|
||||
it("inflates purchased cost for prep loss", () => {
|
||||
@@ -58,5 +65,11 @@ describe("recipe costing", () => {
|
||||
const result = calculateCost(plate, catalogs([base, plate]));
|
||||
expect(result.batch).toBeCloseTo(0.125);
|
||||
expect(result.completeness).toBe(1);
|
||||
expect(result.lines[0]).toMatchObject({
|
||||
subjectId: "dough",
|
||||
kind: "recipe",
|
||||
cost: 0.125,
|
||||
children: [{ subjectId: "flour", kind: "ingredient" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+47
-10
@@ -1,5 +1,5 @@
|
||||
import type { Ingredient, PrepAction, PurchaseItem, Recipe, RecipeItem, Unit } from "./types";
|
||||
import { convertWithIngredientMeasures } from "./measurement";
|
||||
import type { Ingredient, PrepAction, PurchaseItem, Recipe, RecipeItem, Unit } from "./types.ts";
|
||||
import { convertWithIngredientMeasures } from "./measurement.ts";
|
||||
|
||||
export type CostResult = {
|
||||
batch?: number;
|
||||
@@ -10,6 +10,29 @@ export type CostResult = {
|
||||
pricedWeightG: number;
|
||||
completeness: number;
|
||||
warnings: string[];
|
||||
lines: CostLine[];
|
||||
};
|
||||
|
||||
export type CostLine = {
|
||||
id: string;
|
||||
subjectId: string;
|
||||
name: string;
|
||||
kind: "ingredient" | "recipe";
|
||||
cost?: number;
|
||||
weightG?: number;
|
||||
completeness: number;
|
||||
purchase?: {
|
||||
id: string;
|
||||
name: string;
|
||||
packageQuantity: number;
|
||||
packageUnitId: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
effectiveAt: string;
|
||||
supplier?: string;
|
||||
sku?: string;
|
||||
};
|
||||
children?: CostLine[];
|
||||
};
|
||||
|
||||
type Catalogs = {
|
||||
@@ -46,12 +69,12 @@ function usableCostPerGram(ingredient: Ingredient, item: PurchaseItem, currency:
|
||||
}
|
||||
}
|
||||
|
||||
function ingredientRate(ingredient: Ingredient, catalogs: Catalogs, currency: string): number | undefined {
|
||||
function ingredientCostSource(ingredient: Ingredient, catalogs: Catalogs, currency: string) {
|
||||
return [...catalogs.purchaseItems.values()]
|
||||
.filter((item) => item.ingredient_id === ingredient.id && item.status === "active")
|
||||
.map((item) => usableCostPerGram(ingredient, item, currency, catalogs.units))
|
||||
.filter((rate): rate is number => rate != null)
|
||||
.sort((a, b) => a - b)[0];
|
||||
.map((item) => ({ item, rate: usableCostPerGram(ingredient, item, currency, catalogs.units), price: latestPrice(item, currency) }))
|
||||
.filter((entry): entry is typeof entry & { rate: number; price: NonNullable<typeof entry.price> } => entry.rate != null && entry.price != null)
|
||||
.sort((a, b) => a.rate - b.rate)[0];
|
||||
}
|
||||
|
||||
export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "USD", stack: string[] = []): CostResult {
|
||||
@@ -60,6 +83,7 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
|
||||
let inputWeightG = 0;
|
||||
let pricedWeightG = 0;
|
||||
const warnings: string[] = [];
|
||||
const lines: CostLine[] = [];
|
||||
|
||||
for (const item of recipe.components.flatMap((component) => component.items)) {
|
||||
if (item.optional) continue;
|
||||
@@ -71,16 +95,23 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
|
||||
usableWeightG = grams(item.amount.quantity, item.amount.unit_id, ingredient, catalogs.units);
|
||||
} catch (error) {
|
||||
warnings.push(`${ingredient.name}: ${(error as Error).message}`);
|
||||
lines.push({ id:item.id, subjectId:ingredient.id, name:ingredient.name, kind:"ingredient", completeness:0 });
|
||||
continue;
|
||||
}
|
||||
inputWeightG += usableWeightG;
|
||||
const rate = ingredientRate(ingredient, catalogs, currency);
|
||||
if (rate == null) {
|
||||
const source = ingredientCostSource(ingredient, catalogs, currency);
|
||||
if (!source) {
|
||||
warnings.push(`${ingredient.name}: no active ${currency} purchase price with a convertible package size`);
|
||||
lines.push({ id:item.id, subjectId:ingredient.id, name:ingredient.name, kind:"ingredient", weightG:usableWeightG, completeness:0 });
|
||||
continue;
|
||||
}
|
||||
batch += (usableWeightG / prepYieldFactor(item, catalogs)) * rate;
|
||||
const lineCost=(usableWeightG / prepYieldFactor(item, catalogs)) * source.rate;
|
||||
batch += lineCost;
|
||||
pricedWeightG += usableWeightG;
|
||||
lines.push({
|
||||
id:item.id, subjectId:ingredient.id, name:ingredient.name, kind:"ingredient", cost:lineCost, weightG:usableWeightG, completeness:1,
|
||||
purchase:{ id:source.item.id, name:source.item.name, packageQuantity:source.item.package.quantity, packageUnitId:source.item.package.unit_id, price:source.price.amount, currency:source.price.currency, effectiveAt:source.price.effective_at, supplier:source.item.supplier_id, sku:source.item.supplier_sku },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -88,6 +119,7 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
|
||||
if (!child) throw new Error(`Unknown sub-recipe: ${item.reference.recipe_id}`);
|
||||
if (item.reference.component_id) {
|
||||
warnings.push(`${child.title}: component-specific costing is not available for ${item.reference.component_id}`);
|
||||
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", completeness:0 });
|
||||
continue;
|
||||
}
|
||||
const childResult = calculateCost(child, catalogs, currency, [...stack, recipe.id]);
|
||||
@@ -96,6 +128,7 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
|
||||
usedWeightG = convertWithIngredientMeasures(item.amount, "gram", { id: child.id, name: child.title, schema_version: 2, status: "active", categories: [],measure_conversions:child.measure_conversions }, catalogs.units).quantity;
|
||||
} catch (error) {
|
||||
warnings.push(`${child.title}: ${(error as Error).message}`);
|
||||
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", completeness:0, children:childResult.lines });
|
||||
continue;
|
||||
}
|
||||
inputWeightG += usedWeightG;
|
||||
@@ -104,11 +137,14 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
|
||||
: undefined;
|
||||
if (!childYieldG || childResult.batch == null) {
|
||||
warnings.push(`${child.title}: sub-recipe cost requires a positive mass yield and at least one priced input`);
|
||||
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", weightG:usedWeightG, completeness:0, children:childResult.lines });
|
||||
continue;
|
||||
}
|
||||
const factor = usedWeightG / childYieldG;
|
||||
batch += childResult.batch * factor / prepYieldFactor(item, catalogs);
|
||||
const lineCost=childResult.batch * factor / prepYieldFactor(item, catalogs);
|
||||
batch += lineCost;
|
||||
pricedWeightG += usedWeightG * childResult.completeness;
|
||||
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", cost:lineCost, weightG:usedWeightG, completeness:childResult.completeness, children:childResult.lines });
|
||||
warnings.push(...childResult.warnings.map((warning) => `${child.title}: ${warning}`));
|
||||
}
|
||||
|
||||
@@ -128,5 +164,6 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
|
||||
pricedWeightG,
|
||||
completeness: inputWeightG > 0 ? pricedWeightG / inputWeightG : 0,
|
||||
warnings: [...new Set(warnings)],
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
+38
-17
@@ -1,19 +1,40 @@
|
||||
import type { Equipment, Ingredient, PrepAction, PurchaseItem, Recipe, Unit, SourceMapping } from "./types";
|
||||
import { databaseProjection, openDatabase } from "./database";
|
||||
import type {
|
||||
Equipment,
|
||||
Ingredient,
|
||||
PrepAction,
|
||||
PurchaseItem,
|
||||
Recipe,
|
||||
Unit,
|
||||
SourceMapping,
|
||||
} from "./types.ts";
|
||||
import { databaseProjection, openDatabase } from "./database.ts";
|
||||
|
||||
const database = openDatabase();
|
||||
if (!database) throw new Error("Database unavailable. Run npm run db:reset first.");
|
||||
const projection = databaseProjection(database) as {
|
||||
ingredients: Ingredient[]; recipes: Recipe[]; units: Unit[]; equipment: Equipment[];
|
||||
prepActions: PrepAction[]; purchaseItems: PurchaseItem[]; sourceMappings: SourceMapping[];
|
||||
};
|
||||
database.close();
|
||||
export function loadCatalogs() {
|
||||
const database = openDatabase();
|
||||
if (!database) throw new Error("Database unavailable. Restore from backup with: npm run db:restore -- path/to/backup.json");
|
||||
try {
|
||||
const projection = databaseProjection(database) as {
|
||||
ingredients: Ingredient[];
|
||||
recipes: Recipe[];
|
||||
units: Unit[];
|
||||
equipment: Equipment[];
|
||||
prepActions: PrepAction[];
|
||||
purchaseItems: PurchaseItem[];
|
||||
sourceMappings: SourceMapping[];
|
||||
};
|
||||
const map = <T extends { id: string }>(values: T[]) =>
|
||||
new Map(values.map((value) => [value.id, value]));
|
||||
|
||||
const map = <T extends { id: string }>(values: T[]) => new Map(values.map((value) => [value.id, value]));
|
||||
export const ingredients = map(projection.ingredients);
|
||||
export const recipes = map(projection.recipes);
|
||||
export const units = map(projection.units);
|
||||
export const equipment = map(projection.equipment);
|
||||
export const prepActions = map(projection.prepActions);
|
||||
export const purchaseItems = map(projection.purchaseItems);
|
||||
export const sourceMappings = map(projection.sourceMappings);
|
||||
return {
|
||||
ingredients: map(projection.ingredients),
|
||||
recipes: map(projection.recipes),
|
||||
units: map(projection.units),
|
||||
equipment: map(projection.equipment),
|
||||
prepActions: map(projection.prepActions),
|
||||
purchaseItems: map(projection.purchaseItems),
|
||||
sourceMappings: map(projection.sourceMappings),
|
||||
};
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import {
|
||||
duplicateRecipe,
|
||||
editableRecipe,
|
||||
permanentlyDeleteArchivedItems,
|
||||
recipeQualityRows,
|
||||
recipeStructure,
|
||||
restoreArchivedItems,
|
||||
saveRecipeMetadata,
|
||||
saveRecipeStructure,
|
||||
} from "./database";
|
||||
|
||||
function createTestDatabase(): DatabaseSync {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
const schemaPath = path.resolve(process.cwd(), "migrations", "001_initial.sql");
|
||||
const schemaSql = fs.readFileSync(schemaPath, "utf8");
|
||||
db.exec(schemaSql);
|
||||
|
||||
// Seed baseline units
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"gram", "Gram", "g", "mass", "metric", null, null, null
|
||||
);
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"kilogram", "Kilogram", "kg", "mass", "metric", "gram", 1000, null
|
||||
);
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"each", "Each", "ea", "count", "customary", null, null, null
|
||||
);
|
||||
|
||||
// Seed baseline ingredient
|
||||
db.prepare(
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("flour", 2, "All-Purpose Flour", "active", "[]", "[]", "{}");
|
||||
|
||||
// Seed baseline recipe
|
||||
db.prepare(
|
||||
"INSERT INTO recipes(id, schema_version, save_version, title, summary, categories_json, tags_json, yield_quantity, yield_unit_id, yield_servings, yield_basis, notes_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(
|
||||
"bread",
|
||||
2,
|
||||
1,
|
||||
"Country Bread",
|
||||
"Crusty artisan loaf",
|
||||
"[]",
|
||||
"[]",
|
||||
500,
|
||||
"gram",
|
||||
1,
|
||||
"measured",
|
||||
"[]",
|
||||
"{}"
|
||||
);
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO recipe_components(recipe_id, id, position, name, notes_json) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("bread", "comp_1", 1, "Dough", "[]");
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO recipe_items(recipe_id, component_id, id, position, ingredient_id, quantity, unit_id, basis_member, optional) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("bread", "comp_1", "line_1", 1, "flour", 500, "gram", 1, 0);
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO recipe_steps(recipe_id, id, position, instruction, critical_control_point) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("bread", "step_1", 1, "Mix flour and water.", 0);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("database optimistic concurrency", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("updates recipe metadata when save_version matches", () => {
|
||||
const updated = saveRecipeMetadata(db, "bread", 1, {
|
||||
title: "Country Sourdough",
|
||||
summary: "Long-fermented loaf",
|
||||
categories_json: "[]",
|
||||
tags_json: "[]",
|
||||
yield_quantity: 600,
|
||||
yield_unit_id: "gram",
|
||||
yield_servings: 2,
|
||||
yield_basis: "measured",
|
||||
});
|
||||
|
||||
expect(updated.title).toBe("Country Sourdough");
|
||||
expect(updated.save_version).toBe(2);
|
||||
expect(updated.yield_quantity).toBe(600);
|
||||
});
|
||||
|
||||
it("rejects metadata updates when save_version is stale", () => {
|
||||
expect(() =>
|
||||
saveRecipeMetadata(db, "bread", 999, {
|
||||
title: "Conflict Sourdough",
|
||||
summary: null,
|
||||
categories_json: "[]",
|
||||
tags_json: "[]",
|
||||
yield_quantity: 500,
|
||||
yield_unit_id: "gram",
|
||||
yield_servings: 1,
|
||||
yield_basis: null,
|
||||
})
|
||||
).toThrow(/changed in another tab/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recipe structure saving & transactions", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("saves components, lines, steps, and new ingredients atomically", () => {
|
||||
const structure = saveRecipeStructure(db, "bread", {
|
||||
save_version: 1,
|
||||
new_ingredients: [{ id: "water", name: "Filtered Water" }],
|
||||
components: [
|
||||
{
|
||||
id: "comp_1",
|
||||
name: "Main Dough",
|
||||
items: [
|
||||
{
|
||||
id: "line_1",
|
||||
ingredient_id: "flour",
|
||||
quantity: 400,
|
||||
unit_id: "gram",
|
||||
basis_member: true,
|
||||
optional: false,
|
||||
prep: [],
|
||||
},
|
||||
{
|
||||
id: "line_2",
|
||||
ingredient_id: "water",
|
||||
quantity: 300,
|
||||
unit_id: "gram",
|
||||
basis_member: false,
|
||||
optional: false,
|
||||
prep: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
id: "step_1",
|
||||
instruction: "Combine flour and water.",
|
||||
equipment_ids: [],
|
||||
},
|
||||
{
|
||||
id: "step_2",
|
||||
instruction: "Bake at 450F.",
|
||||
equipment_ids: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(structure.save_version).toBe(2);
|
||||
expect(structure.components[0].items).toHaveLength(2);
|
||||
expect(structure.steps).toHaveLength(2);
|
||||
|
||||
// Verify new ingredient was created
|
||||
const water = db.prepare("SELECT * FROM ingredients WHERE id = ?").get("water") as any;
|
||||
expect(water).toBeDefined();
|
||||
expect(water.name).toBe("Filtered Water");
|
||||
});
|
||||
|
||||
it("rejects save without components or steps", () => {
|
||||
expect(() =>
|
||||
saveRecipeStructure(db, "bread", {
|
||||
save_version: 1,
|
||||
components: [],
|
||||
steps: [{ id: "step_1", instruction: "Mix.", equipment_ids: [] }],
|
||||
})
|
||||
).toThrow(/at least one component/);
|
||||
|
||||
expect(() =>
|
||||
saveRecipeStructure(db, "bread", {
|
||||
save_version: 1,
|
||||
components: [{ id: "c1", name: "Dough", items: [{ id: "l1", ingredient_id: "flour", quantity: 100, unit_id: "gram", basis_member: false, optional: false, prep: [] }] }],
|
||||
steps: [],
|
||||
})
|
||||
).toThrow(/at least one preparation step/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recipe duplication", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("creates a deep clone with a unique ID and duplicate title", () => {
|
||||
const copyId = duplicateRecipe(db, "bread");
|
||||
expect(copyId).toBe("bread_copy");
|
||||
|
||||
const copy = editableRecipe(db, copyId);
|
||||
expect(copy).toBeDefined();
|
||||
expect(copy?.title).toBe("Country Bread Copy");
|
||||
expect(copy?.save_version).toBe(1);
|
||||
|
||||
const copyStruct = recipeStructure(db, copyId);
|
||||
expect(copyStruct?.components).toHaveLength(1);
|
||||
expect(copyStruct?.steps).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recipe quality metrics", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("identifies placeholder steps and unpriced items", () => {
|
||||
// Add a placeholder step
|
||||
db.prepare(
|
||||
"INSERT INTO recipe_steps(recipe_id, id, position, instruction, critical_control_point) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("bread", "step_todo", 2, "TODO: define baking temperature", 0);
|
||||
|
||||
const rows = recipeQualityRows(db);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].placeholder_steps).toBe(1);
|
||||
expect(rows[0].unpriced_items).toBe(1); // flour has no purchase items yet
|
||||
expect(rows[0].total_items).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive lifecycle & safe hard deletion", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("restores soft-deleted items across multiple entity types", () => {
|
||||
// Soft delete bread recipe and flour ingredient
|
||||
db.prepare("UPDATE recipes SET deleted_at = '2026-08-01' WHERE id = 'bread'").run();
|
||||
db.prepare("UPDATE ingredients SET deleted_at = '2026-08-01', status = 'archived' WHERE id = 'flour'").run();
|
||||
|
||||
restoreArchivedItems(db, [
|
||||
{ id: "bread", type: "recipe" },
|
||||
{ id: "flour", type: "ingredient" },
|
||||
]);
|
||||
|
||||
const bread = db.prepare("SELECT deleted_at FROM recipes WHERE id = 'bread'").get() as any;
|
||||
const flour = db.prepare("SELECT deleted_at, status FROM ingredients WHERE id = 'flour'").get() as any;
|
||||
|
||||
expect(bread.deleted_at).toBeNull();
|
||||
expect(flour.deleted_at).toBeNull();
|
||||
expect(flour.status).toBe("active");
|
||||
});
|
||||
|
||||
it("blocks permanent deletion of ingredients used in active recipes", () => {
|
||||
// Flour is used in active bread recipe
|
||||
expect(() =>
|
||||
permanentlyDeleteArchivedItems(db, [{ id: "flour", type: "ingredient" }])
|
||||
).toThrow(/currently used in active recipe/);
|
||||
});
|
||||
|
||||
it("permanently purges items when no active dependencies exist", () => {
|
||||
// Create an unreferenced ingredient
|
||||
db.prepare(
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("salt", 2, "Kosher Salt", "archived", "[]", "[]", "{}", "2026-08-01");
|
||||
|
||||
permanentlyDeleteArchivedItems(db, [{ id: "salt", type: "ingredient" }]);
|
||||
|
||||
const salt = db.prepare("SELECT 1 FROM ingredients WHERE id = 'salt'").get();
|
||||
expect(salt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+105
-2
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { createSiteProjection, writeSiteProjection } from "../../scripts/lib/site-projection.mjs";
|
||||
import { readOnlyMode } from "./runtime";
|
||||
import { readOnlyMode } from "./runtime.ts";
|
||||
|
||||
export const databasePath = path.resolve(process.cwd(), "var/recipe-book.sqlite");
|
||||
export const refreshSiteProjection = (database: DatabaseSync) => writeSiteProjection(database);
|
||||
@@ -45,6 +45,7 @@ export function saveRecipeMetadata(database: DatabaseSync, id: string, expectedV
|
||||
|
||||
export type RecipeStructure = {
|
||||
save_version: number;
|
||||
new_ingredients?: Array<{ id: string; name: string }>;
|
||||
metadata?: {
|
||||
title: string; yield_quantity: number; yield_unit_id: string; yield_servings: number | null; yield_basis: string | null;
|
||||
station?: string | null; cover_media_url?: string | null; tags?: string[];
|
||||
@@ -70,6 +71,13 @@ export function saveRecipeStructure(database: DatabaseSync, id: string, structur
|
||||
if (current.save_version !== structure.save_version) throw new Error("This recipe changed in another tab. Reload before saving.");
|
||||
if (!structure.components.length) throw new Error("A recipe needs at least one component.");
|
||||
if (!structure.steps.length) throw new Error("A recipe needs at least one preparation step.");
|
||||
const newIngredients = new Map<string,string>();
|
||||
for (const ingredient of structure.new_ingredients ?? []) {
|
||||
const ingredientId=ingredient.id.trim(),name=ingredient.name.trim();
|
||||
if(!/^[a-z0-9][a-z0-9_]*$/.test(ingredientId)||!name||newIngredients.has(ingredientId))throw new Error("Imported ingredients need unique names and stable IDs.");
|
||||
if(database.prepare("SELECT 1 FROM ingredients WHERE id=?").get(ingredientId))throw new Error(`Ingredient ${ingredientId} already exists. Reload and try again.`);
|
||||
newIngredients.set(ingredientId,name);
|
||||
}
|
||||
if (structure.metadata) {
|
||||
if (!structure.metadata.title.trim()) throw new Error("Recipe name is required.");
|
||||
if (!Number.isFinite(structure.metadata.yield_quantity) || structure.metadata.yield_quantity <= 0) throw new Error("Total yield must be greater than zero.");
|
||||
@@ -90,7 +98,7 @@ export function saveRecipeStructure(database: DatabaseSync, id: string, structur
|
||||
if (!Number.isFinite(item.quantity) || item.quantity <= 0) throw new Error(`${item.id} needs a positive quantity.`);
|
||||
if(item.nutrition_retention_factor!=null&&(!Number.isFinite(item.nutrition_retention_factor)||item.nutrition_retention_factor<0||item.nutrition_retention_factor>1))throw new Error(`${item.id} nutrition retention must be between 0 and 1.`);
|
||||
if (!database.prepare("SELECT 1 FROM units WHERE id = ?").get(item.unit_id)) throw new Error(`${item.id} uses an unknown unit.`);
|
||||
if (item.ingredient_id && !database.prepare("SELECT 1 FROM ingredients WHERE id = ?").get(item.ingredient_id)) throw new Error(`${item.id} references an unknown ingredient.`);
|
||||
if (item.ingredient_id && !newIngredients.has(item.ingredient_id) && !database.prepare("SELECT 1 FROM ingredients WHERE id = ?").get(item.ingredient_id)) throw new Error(`${item.id} references an unknown ingredient.`);
|
||||
if (item.subrecipe_id && (!database.prepare("SELECT 1 FROM recipes WHERE id = ?").get(item.subrecipe_id) || item.subrecipe_id === id)) throw new Error(`${item.id} references an invalid sub-recipe.`);
|
||||
}
|
||||
}
|
||||
@@ -102,6 +110,8 @@ export function saveRecipeStructure(database: DatabaseSync, id: string, structur
|
||||
const nextVersion = current.save_version + 1;
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const ingredientInsert=database.prepare("INSERT INTO ingredients(id,schema_version,name,status,categories_json,tags_json,source_json) VALUES (?,2,?,'active','[]','[]',?)");
|
||||
for(const [ingredientId,name] of newIngredients)ingredientInsert.run(ingredientId,name,JSON.stringify({source_type:"ai_import",title:"Recipe ingredient import",reviewed:false}));
|
||||
database.prepare("DELETE FROM recipe_steps WHERE recipe_id = ?").run(id);
|
||||
database.prepare("DELETE FROM recipe_components WHERE recipe_id = ?").run(id);
|
||||
const componentInsert = database.prepare("INSERT INTO recipe_components(recipe_id, id, position, name, notes_json) VALUES (?, ?, ?, ?, ?)");
|
||||
@@ -170,3 +180,96 @@ export function recipeQualityRows(database: DatabaseSync): QualityRecipe[] {
|
||||
ORDER BY r.title
|
||||
`).all() as unknown as QualityRecipe[];
|
||||
}
|
||||
|
||||
export function restoreArchivedItems(
|
||||
database: DatabaseSync,
|
||||
items: Array<{ id: string; type: string }>
|
||||
) {
|
||||
const tables: Record<string, string> = {
|
||||
recipe: "recipes",
|
||||
ingredient: "ingredients",
|
||||
book: "collections",
|
||||
purchase: "purchase_items",
|
||||
};
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
for (const item of items) {
|
||||
const table = tables[item.type];
|
||||
if (table) {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE ${table} SET deleted_at = NULL${
|
||||
item.type === "ingredient" ? ", status = 'active'" : ""
|
||||
} WHERE id = ?`
|
||||
)
|
||||
.run(item.id);
|
||||
}
|
||||
}
|
||||
database.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
refreshSiteProjection(database);
|
||||
}
|
||||
|
||||
export function permanentlyDeleteArchivedItems(
|
||||
database: DatabaseSync,
|
||||
items: Array<{ id: string; type: string }>
|
||||
) {
|
||||
// Check dependencies first
|
||||
for (const item of items) {
|
||||
if (item.type === "ingredient") {
|
||||
const activeRecipe = database
|
||||
.prepare(
|
||||
`SELECT r.title FROM recipe_items ri
|
||||
JOIN recipes r ON r.id = ri.recipe_id
|
||||
WHERE ri.ingredient_id = ? AND r.deleted_at IS NULL
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(item.id) as { title: string } | undefined;
|
||||
if (activeRecipe) {
|
||||
throw new Error(
|
||||
`Cannot delete ingredient '${item.id}' because it is currently used in active recipe '${activeRecipe.title}'. Remove it from the recipe first.`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (item.type === "recipe") {
|
||||
const parentRecipe = database
|
||||
.prepare(
|
||||
`SELECT r.title FROM recipe_items ri
|
||||
JOIN recipes r ON r.id = ri.recipe_id
|
||||
WHERE ri.subrecipe_id = ? AND r.deleted_at IS NULL
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(item.id) as { title: string } | undefined;
|
||||
if (parentRecipe) {
|
||||
throw new Error(
|
||||
`Cannot delete recipe '${item.id}' because it is used as a sub-recipe in active recipe '${parentRecipe.title}'.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tables: Record<string, string> = {
|
||||
recipe: "recipes",
|
||||
ingredient: "ingredients",
|
||||
book: "collections",
|
||||
purchase: "purchase_items",
|
||||
};
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
for (const item of items) {
|
||||
const table = tables[item.type];
|
||||
if (table) {
|
||||
database.prepare(`DELETE FROM ${table} WHERE id = ?`).run(item.id);
|
||||
}
|
||||
}
|
||||
database.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
refreshSiteProjection(database);
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { number, roundForDisplay } from "./format";
|
||||
import { number, roundForDisplay, titleCase } from "./format";
|
||||
|
||||
describe("display number formatting", () => {
|
||||
it("uses two decimal places for ordinary values", () => {
|
||||
@@ -17,3 +17,14 @@ describe("display number formatting", () => {
|
||||
expect(number(0)).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("title case formatting", () => {
|
||||
it("capitalizes the first letter of each word without lowercasing source data", () => {
|
||||
expect(titleCase("baking powder, double-acting")).toBe("Baking Powder, Double-acting");
|
||||
expect(titleCase("USDA choice beef")).toBe("USDA Choice Beef");
|
||||
});
|
||||
|
||||
it("preserves punctuation and whitespace-separated numeric tokens", () => {
|
||||
expect(titleCase("2% milk")).toBe("2% Milk");
|
||||
});
|
||||
});
|
||||
|
||||
+12
-1
@@ -1,4 +1,4 @@
|
||||
import type { Amount, Unit } from "./types";
|
||||
import type { Amount, Unit } from "./types.ts";
|
||||
|
||||
export function displayDigits(value: number) {
|
||||
return value !== 0 && Math.abs(value) <= 0.1 ? 3 : 2;
|
||||
@@ -13,6 +13,17 @@ export function number(value: number) {
|
||||
return new Intl.NumberFormat("en-US", { maximumFractionDigits: displayDigits(value) }).format(value);
|
||||
}
|
||||
|
||||
export function titleCase(input: string) {
|
||||
return input
|
||||
.split(/\s+/)
|
||||
.map((word) => {
|
||||
const index = word.search(/\p{L}/u);
|
||||
if (index === -1) return word;
|
||||
return word.slice(0, index) + word[index].toLocaleUpperCase() + word.slice(index + 1);
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function amount(value: Amount, units: Map<string, Unit>) {
|
||||
if (value.display) return value.display;
|
||||
const unit = units.get(value.unit_id);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeIngredientText, validateIngredientParse } from "./ingredient-parser";
|
||||
|
||||
describe("ingredient parser", () => {
|
||||
it("normalizes copied checklist text and Unicode fractions", () => {
|
||||
expect(normalizeIngredientText("▢1 pound beef\n☐ ½ cup water\n▢salt, , to taste"))
|
||||
.toBe("1 pound beef\n1/2 cup water\nsalt, to taste");
|
||||
});
|
||||
|
||||
it("validates a structured parser response", () => {
|
||||
expect(validateIngredientParse({
|
||||
components: [{ name:"Main", items:[{
|
||||
source_line:"1 pound beef", quantity:1, unit:"pound", ingredient:"ground beef",
|
||||
preparation:null, note:null, optional:false, alternatives:[],
|
||||
}] }], warnings:[],
|
||||
}, "1 pound beef")).toMatchObject({ normalized_text:"1 pound beef", components:[{name:"Main"}] });
|
||||
});
|
||||
|
||||
it("rejects invented or malformed quantities", () => {
|
||||
expect(() => validateIngredientParse({
|
||||
components: [{ name:"Main", items:[{
|
||||
source_line:"salt", quantity:-1, unit:null, ingredient:"salt",
|
||||
preparation:null, note:null, optional:false, alternatives:[],
|
||||
}] }], warnings:[],
|
||||
}, "salt")).toThrow("invalid quantity");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
export type ParsedIngredient = {
|
||||
source_line: string;
|
||||
quantity: number | null;
|
||||
unit: string | null;
|
||||
ingredient: string;
|
||||
preparation: string | null;
|
||||
note: string | null;
|
||||
optional: boolean;
|
||||
alternatives: string[];
|
||||
};
|
||||
|
||||
export type ParsedIngredientComponent = {
|
||||
name: string;
|
||||
items: ParsedIngredient[];
|
||||
};
|
||||
|
||||
export type IngredientParseResult = {
|
||||
normalized_text: string;
|
||||
components: ParsedIngredientComponent[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
const FRACTIONS: Record<string, string> = {
|
||||
"¼": "1/4", "½": "1/2", "¾": "3/4", "⅐": "1/7", "⅑": "1/9",
|
||||
"⅒": "1/10", "⅓": "1/3", "⅔": "2/3", "⅕": "1/5", "⅖": "2/5",
|
||||
"⅗": "3/5", "⅘": "4/5", "⅙": "1/6", "⅚": "5/6", "⅛": "1/8",
|
||||
"⅜": "3/8", "⅝": "5/8", "⅞": "7/8",
|
||||
};
|
||||
|
||||
export function normalizeIngredientText(value: string): string {
|
||||
return value
|
||||
.replace(/[¼½¾⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞]/g, (value) => FRACTIONS[value] ?? value)
|
||||
.normalize("NFKC")
|
||||
.replace(/⁄/g, "/")
|
||||
.replace(/[\u200B-\u200D\u2060\uFEFF]/g, "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line
|
||||
.replace(/^\s*(?:[▢□☐☑✓✔●•▪◦]|\[(?: |x|X)?\])\s*/, "")
|
||||
.replace(/\s*,\s*,+/g, ",")
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.replace(/\s+,/g, ",")
|
||||
.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const responseSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["components", "warnings"],
|
||||
properties: {
|
||||
components: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["name", "items"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["source_line", "quantity", "unit", "ingredient", "preparation", "note", "optional", "alternatives"],
|
||||
properties: {
|
||||
source_line: { type: "string" },
|
||||
quantity: { type: ["number", "null"] },
|
||||
unit: { type: ["string", "null"] },
|
||||
ingredient: { type: "string" },
|
||||
preparation: { type: ["string", "null"] },
|
||||
note: { type: ["string", "null"] },
|
||||
optional: { type: "boolean" },
|
||||
alternatives: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
warnings: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const systemPrompt = `You are a purpose-built culinary ingredient parser. Convert normalized recipe ingredient text into JSON only.
|
||||
|
||||
Rules:
|
||||
- Preserve the meaning and never invent an ingredient, amount, unit, or preparation.
|
||||
- Convert fractions and mixed numbers to decimal quantities.
|
||||
- Use singular conventional unit names such as gram, ounce, pound, teaspoon, tablespoon, cup, milliliter, liter, or each.
|
||||
- A line ending in a colon is a component header. Use "Main" when there is no header.
|
||||
- Group consecutive lines under one component. Do not create a new Main component for each line.
|
||||
- ingredient contains only the ingredient identity, never its quantity, unit, size, preparation, or note. For example, "15 ounces tomato sauce" becomes quantity 15, unit "ounce", ingredient "tomato sauce".
|
||||
- Treat sizes such as small, medium, and large as preparation or notes and use unit "each". For example, "1 medium onion, chopped" becomes quantity 1, unit "each", ingredient "onion", preparation "medium; chopped".
|
||||
- Split a source line containing two independently required ingredients into two items, retaining the same source_line.
|
||||
- Specifically, "salt and freshly ground black pepper, to taste" becomes separate salt and black pepper items.
|
||||
- Keep alternatives in alternatives instead of adding them as required items.
|
||||
- Put physical treatment such as chopped, minced, sliced, freshly ground, dried, or drained in preparation.
|
||||
- Put serving instructions, "to taste", temperatures, and other qualifications in note.
|
||||
- Set optional true when the source explicitly says optional.
|
||||
- Use null quantity and unit when the source supplies none. Do not guess.
|
||||
- Use null rather than an empty string. Do not repeat alternatives in note.
|
||||
- Retain every source line. Add a warning for ambiguity.`;
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
export function validateIngredientParse(value: unknown, normalizedText: string): IngredientParseResult {
|
||||
if (!value || typeof value !== "object") throw new Error("The parser returned an invalid document.");
|
||||
const source = value as Record<string, unknown>;
|
||||
if (!Array.isArray(source.components) || !Array.isArray(source.warnings)) throw new Error("The parser response is missing components or warnings.");
|
||||
const components = source.components.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") throw new Error("The parser returned an invalid component.");
|
||||
const component = entry as Record<string, unknown>;
|
||||
if (typeof component.name !== "string" || !component.name.trim() || !Array.isArray(component.items)) throw new Error("The parser returned an invalid component.");
|
||||
const items = component.items.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") throw new Error("The parser returned an invalid ingredient.");
|
||||
const item = entry as Record<string, unknown>;
|
||||
if (typeof item.source_line !== "string") throw new Error("The parser returned an ingredient without its source line.");
|
||||
if (typeof item.ingredient !== "string" || !item.ingredient.trim()) throw new Error(`The parser returned an unnamed ingredient for: ${item.source_line}`);
|
||||
if (!(item.quantity === null || typeof item.quantity === "number" && Number.isFinite(item.quantity) && item.quantity > 0)) throw new Error(`The parser returned an invalid quantity for: ${item.source_line}`);
|
||||
if (!isNullableString(item.unit) || !isNullableString(item.preparation) || !isNullableString(item.note)) throw new Error(`The parser returned invalid text fields for: ${item.source_line}`);
|
||||
if (typeof item.optional !== "boolean" || !Array.isArray(item.alternatives) || !item.alternatives.every((value) => typeof value === "string")) throw new Error(`The parser returned invalid qualifications for: ${item.source_line}`);
|
||||
const sourceLine = item.source_line.trim();
|
||||
const sourceSaysOptional = /\boptional\b/i.test(sourceLine);
|
||||
const alternatives = (item.alternatives as string[]).map((value) => value.trim()).filter((value) => value && !/^(?:none|optional)$/i.test(value));
|
||||
const note = item.note?.trim() || null;
|
||||
return {
|
||||
source_line: sourceLine,
|
||||
quantity: item.quantity as number | null,
|
||||
unit: item.unit?.trim().toLowerCase() || null,
|
||||
ingredient: item.ingredient.trim(),
|
||||
preparation: item.preparation?.trim() || null,
|
||||
note: note && (!/^optional$/i.test(note) || sourceSaysOptional) ? note : null,
|
||||
optional: sourceSaysOptional,
|
||||
alternatives,
|
||||
} satisfies ParsedIngredient;
|
||||
});
|
||||
return { name: component.name.trim(), items };
|
||||
}).filter((component) => component.items.length > 0);
|
||||
if (!components.length) throw new Error("The parser did not find any ingredients.");
|
||||
if (!source.warnings.every((value) => typeof value === "string")) throw new Error("The parser returned invalid warnings.");
|
||||
const consolidated: ParsedIngredientComponent[] = [];
|
||||
for (const component of components) {
|
||||
const previous = consolidated.at(-1);
|
||||
if (previous?.name.toLowerCase() === component.name.toLowerCase()) previous.items.push(...component.items);
|
||||
else consolidated.push(component);
|
||||
}
|
||||
return { normalized_text: normalizedText, components: consolidated, warnings: source.warnings as string[] };
|
||||
}
|
||||
|
||||
export async function parseIngredientsWithOllama(text: string): Promise<IngredientParseResult> {
|
||||
const normalizedText = normalizeIngredientText(text);
|
||||
if (!normalizedText) throw new Error("Enter at least one ingredient.");
|
||||
const endpoint = process.env.FORMULATION_OLLAMA_URL ?? "http://10.0.10.211:11434/api/chat";
|
||||
const model = process.env.FORMULATION_INGREDIENT_PARSER_MODEL ?? "qwen3:4b-instruct";
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
signal: AbortSignal.timeout(90_000),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
stream: false,
|
||||
think: false,
|
||||
format: responseSchema,
|
||||
options: { temperature: 0, num_predict: 6000 },
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: normalizedText },
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Ingredient parser service failed (${response.status}).`);
|
||||
const payload = await response.json() as { message?: { content?: string } };
|
||||
const content = payload.message?.content?.trim();
|
||||
if (!content) throw new Error("Ingredient parser returned an empty response.");
|
||||
let parsed: unknown;
|
||||
const unwrapped = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
||||
const firstBrace = unwrapped.indexOf("{");
|
||||
const lastBrace = unwrapped.lastIndexOf("}");
|
||||
const json = firstBrace >= 0 && lastBrace > firstBrace ? unwrapped.slice(firstBrace, lastBrace + 1) : unwrapped;
|
||||
try { parsed = JSON.parse(json); }
|
||||
catch { throw new Error("Ingredient parser returned malformed JSON."); }
|
||||
return validateIngredientParse(parsed, normalizedText);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import {
|
||||
createInventoryCount,
|
||||
getInventoryCountDetail,
|
||||
getInventoryCounts,
|
||||
getInventoryLocations,
|
||||
saveInventoryCountItems,
|
||||
} from "./repository/inventory-repository";
|
||||
|
||||
function createTestDatabase(): DatabaseSync {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
const schemaPath = path.resolve(process.cwd(), "migrations", "001_initial.sql");
|
||||
db.exec(fs.readFileSync(schemaPath, "utf8"));
|
||||
const invSchemaPath = path.resolve(process.cwd(), "migrations", "003_inventory.sql");
|
||||
db.exec(fs.readFileSync(invSchemaPath, "utf8"));
|
||||
|
||||
// Seed baseline units
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"gram", "Gram", "g", "mass", "metric", null, null, null
|
||||
);
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"each", "Each", "ea", "count", "customary", null, null, null
|
||||
);
|
||||
|
||||
// Seed ingredients
|
||||
db.prepare(
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("butter", 2, "Unsalted Butter", "active", "[]", "[]", "{}");
|
||||
db.prepare(
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("flour", 2, "Bread Flour", "active", "[]", "[]", "{}");
|
||||
|
||||
// Seed purchase item for butter: $5.00 for 500g ($0.01/g)
|
||||
db.prepare(
|
||||
"INSERT INTO purchase_items(id, ingredient_id, name, status, package_quantity, package_unit_id, units_per_case, usable_yield_factor) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("pi_butter", "butter", "Butter 500g", "active", 500, "gram", 1, 1);
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO price_observations(purchase_item_id, effective_at, currency, amount, source_json) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("pi_butter", "2026-08-01", "USD", 5.0, "{}");
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("inventory repository", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("fetches seeded inventory locations", () => {
|
||||
const locations = getInventoryLocations(db);
|
||||
expect(locations.length).toBeGreaterThanOrEqual(4);
|
||||
expect(locations.map((l) => l.name)).toContain("Walk-in Cooler");
|
||||
expect(locations.map((l) => l.name)).toContain("Dry Storage");
|
||||
});
|
||||
|
||||
it("creates a count session with pre-populated active ingredients", () => {
|
||||
const countId = createInventoryCount(db, {
|
||||
title: "End of Month Count - August 2026",
|
||||
counted_at: "2026-08-31",
|
||||
notes: "Routine inventory count",
|
||||
prepopulate: true,
|
||||
});
|
||||
|
||||
expect(countId).toBeDefined();
|
||||
|
||||
const detail = getInventoryCountDetail(db, countId);
|
||||
expect(detail).toBeDefined();
|
||||
expect(detail?.title).toBe("End of Month Count - August 2026");
|
||||
expect(detail?.status).toBe("open");
|
||||
expect(detail?.items).toHaveLength(2); // butter and flour
|
||||
});
|
||||
|
||||
it("saves count quantities, calculates extended valuation, and marks completed", () => {
|
||||
const countId = createInventoryCount(db, {
|
||||
title: "Weekly Count",
|
||||
counted_at: "2026-08-17",
|
||||
prepopulate: true,
|
||||
});
|
||||
|
||||
const locations = getInventoryLocations(db);
|
||||
const walkIn = locations.find((l) => l.name === "Walk-in Cooler")!;
|
||||
const dryStorage = locations.find((l) => l.name === "Dry Storage")!;
|
||||
|
||||
// Count 1000g of butter ($0.01/g = $10.00) in walk-in, and 2000g of flour (unpriced = $0.00) in dry storage
|
||||
saveInventoryCountItems(
|
||||
db,
|
||||
countId,
|
||||
[
|
||||
{
|
||||
location_id: walkIn.id,
|
||||
ingredient_id: "butter",
|
||||
quantity: 1000,
|
||||
unit_id: "gram",
|
||||
},
|
||||
{
|
||||
location_id: dryStorage.id,
|
||||
ingredient_id: "flour",
|
||||
quantity: 2000,
|
||||
unit_id: "gram",
|
||||
},
|
||||
],
|
||||
"completed"
|
||||
);
|
||||
|
||||
const detail = getInventoryCountDetail(db, countId);
|
||||
expect(detail?.status).toBe("completed");
|
||||
expect(detail?.total_value).toBe(10.0);
|
||||
|
||||
const counts = getInventoryCounts(db);
|
||||
expect(counts).toHaveLength(1);
|
||||
expect(counts[0].status).toBe("completed");
|
||||
expect(counts[0].total_value).toBe(10.0);
|
||||
expect(counts[0].item_count).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ingredients, units } from "./data";
|
||||
import { loadCatalogs } from "./data";
|
||||
import { convert, convertWithDensity, convertWithIngredientMeasures, densityInGramsPerMilliliter } from "./measurement";
|
||||
|
||||
const { ingredients, units } = loadCatalogs();
|
||||
|
||||
describe("normalized measurement conversion", () => {
|
||||
it("converts mass through canonical grams", () => {
|
||||
expect(convert(1, "pound", "ounce_mass", units)).toBeCloseTo(16, 8);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user