Compare commits
2
Commits
1f783e5a41
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
011a479839 | ||
|
|
2a1e16ed30 |
+6
-4
@@ -31,8 +31,10 @@ 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
|
||||
database.sqlite
|
||||
/scratch/
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,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
+12
-4
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "recipe-book",
|
||||
"name": "formulation",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -19,20 +19,28 @@
|
||||
"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:reset": "node scripts/backup.mjs import data/recipe-book-backup.json",
|
||||
"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,51 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
const doc = JSON.parse(fs.readFileSync("file.json", "utf8"));
|
||||
|
||||
function isProcedureHeader(text, label) {
|
||||
if (label !== "section_header") return false;
|
||||
const s = text.trim();
|
||||
if (/^chef's notes?:?$/i.test(s)) return false;
|
||||
if (/^table of contents|foreword|introduction|acknowledgements|how to use/i.test(s)) return false;
|
||||
return /procedure|assembly|variations?|baking|shaping|finishing|glaz|infusion/i.test(s) || s.endsWith(":");
|
||||
}
|
||||
|
||||
function cleanProcedureHeader(raw) {
|
||||
const t = raw.trim().replace(/^['"]+|['":]+$/g, "").trim();
|
||||
if (/^procedure$/i.test(t)) return "";
|
||||
const cleaned = t.replace(/\s+procedure$/i, "").trim();
|
||||
return cleaned ? `${cleaned}:` : "";
|
||||
}
|
||||
|
||||
function testPages(pages, title) {
|
||||
const texts = doc.texts.filter(t => pages.includes(t.prov?.[0]?.page_no));
|
||||
console.log(`\nTesting on ${title} (pages ${pages.join("-")}):`);
|
||||
|
||||
let inProcedure = false;
|
||||
const steps = [];
|
||||
|
||||
for (const t of texts) {
|
||||
const text = t.text.trim();
|
||||
if (/^chef's notes?:?$/i.test(text)) {
|
||||
inProcedure = false;
|
||||
continue;
|
||||
}
|
||||
if (isProcedureHeader(text, t.label)) {
|
||||
inProcedure = true;
|
||||
const heading = cleanProcedureHeader(text);
|
||||
if (heading) {
|
||||
steps.push({ id: `step_${steps.length + 1}`, order: steps.length + 1, instruction: heading });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inProcedure && (t.label === "list_item" || t.label === "text") && !/^\d+$/.test(text) && !/^yields?:/i.test(text)) {
|
||||
steps.push({ id: `step_${steps.length + 1}`, order: steps.length + 1, instruction: text });
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Extracted steps count:", steps.length);
|
||||
steps.forEach(s => console.log(" " + (s.instruction.endsWith(":") ? "📂 " : " • ") + s.instruction));
|
||||
}
|
||||
|
||||
testPages([312, 313], "Fig Bars");
|
||||
testPages([26, 27], "Chocolate Puff Pastry");
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/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");
|
||||
const defaultSeedPath = path.join(root, "data", "recipe-book-backup.json");
|
||||
|
||||
// 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 (defaults to data/recipe-book-backup.json).
|
||||
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.
|
||||
`);
|
||||
}
|
||||
|
||||
function ensureDatabaseSchema(db) {
|
||||
const migrationsDir = path.join(root, "migrations");
|
||||
if (fs.existsSync(migrationsDir)) {
|
||||
const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort();
|
||||
for (const file of files) {
|
||||
try {
|
||||
const sql = fs.readFileSync(path.join(migrationsDir, file), "utf8");
|
||||
db.exec(sql);
|
||||
} catch {
|
||||
// Schema migrations may already be partially or fully applied
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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] || defaultSeedPath;
|
||||
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 fileArg = args.find((arg, idx) => idx > 0 && !arg.startsWith("--"));
|
||||
const inputPath = fileArg || (fs.existsSync(defaultSeedPath) ? defaultSeedPath : null);
|
||||
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"));
|
||||
|
||||
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
|
||||
const db = new DatabaseSync(databasePath);
|
||||
ensureDatabaseSchema(db);
|
||||
|
||||
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,795 @@
|
||||
#!/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;
|
||||
}
|
||||
|
||||
function isProcedureHeader(text, label, recipeTitle) {
|
||||
if (label !== "section_header") return false;
|
||||
const s = text.trim();
|
||||
if (recipeTitle && s.toLowerCase() === recipeTitle.toLowerCase()) return false;
|
||||
if (/^chef's notes?:?$/i.test(s)) return false;
|
||||
if (/^table of contents|foreword|introduction|acknowledgements|how to use/i.test(s)) return false;
|
||||
return /procedure|assembly|variations?|baking|shaping|finishing|glaz|infusion/i.test(s) || s.endsWith(":");
|
||||
}
|
||||
|
||||
function cleanProcedureHeader(raw) {
|
||||
const t = raw.trim().replace(/^['"]+|['":]+$/g, "").trim();
|
||||
if (/^procedure$/i.test(t)) return "";
|
||||
const cleaned = t.replace(/\s+procedure$/i, "").trim();
|
||||
return cleaned ? `${cleaned}:` : "";
|
||||
}
|
||||
|
||||
const steps = [];
|
||||
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;
|
||||
}
|
||||
|
||||
if (/^chef's notes?:?$/i.test(text)) {
|
||||
inProcedure = false;
|
||||
inChefNotes = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isProcedureHeader(text, textNode.label, title)) {
|
||||
inProcedure = true;
|
||||
inChefNotes = false;
|
||||
const heading = cleanProcedureHeader(text);
|
||||
if (heading) {
|
||||
steps.push({
|
||||
id: `step_${steps.length + 1}`,
|
||||
order: steps.length + 1,
|
||||
instruction: heading,
|
||||
equipment_ids: [],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inProcedure) {
|
||||
if ((textNode.label === "list_item" || textNode.label === "text") && !/^\d+$/.test(text) && !/^yields?:/i.test(text)) {
|
||||
steps.push({
|
||||
id: `step_${steps.length + 1}`,
|
||||
order: steps.length + 1,
|
||||
instruction: 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:`);
|
||||
let stepNum = 1;
|
||||
for (const step of recipe.steps) {
|
||||
const isHeading = step.instruction.endsWith(":");
|
||||
if (isHeading) {
|
||||
console.log(`\n 📂 ${step.instruction}`);
|
||||
stepNum = 1;
|
||||
} else {
|
||||
const eq = step.equipment_ids.length > 0 ? ` [Equip: ${step.equipment_ids.join(", ")}]` : "";
|
||||
console.log(` ${stepNum++}. ${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,666 @@
|
||||
---
|
||||
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";
|
||||
import { TYPE_ICONS, TYPE_ICON_TRANSFORMS, type EntityIconKey } from "../../../lib/icons";
|
||||
|
||||
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">
|
||||
<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]}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path
|
||||
d={TYPE_ICONS[item.type as EntityIconKey]}
|
||||
style={TYPE_ICON_TRANSFORMS[item.type as EntityIconKey] ? { transform: TYPE_ICON_TRANSFORMS[item.type as EntityIconKey] } : undefined}
|
||||
/>
|
||||
</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-page {
|
||||
background: #ffffff;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.archive-page :global(.detail-utility) {
|
||||
position: sticky !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100% !important;
|
||||
z-index: 30 !important;
|
||||
background: #ffffff !important;
|
||||
border-bottom: 1px solid #edf0f5 !important;
|
||||
}
|
||||
|
||||
.archive-workspace {
|
||||
width: min(1120px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
padding: 32px 0 64px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.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-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.archive-header h1 {
|
||||
margin: 0 0 6px;
|
||||
color: #050841;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.archive-subtitle {
|
||||
color: #8b93a7;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.archive-toolbar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.archive-filter-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.archive-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
padding: 6px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 999px;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.archive-chip:hover {
|
||||
background: #fff;
|
||||
border-color: #cfd5fa;
|
||||
}
|
||||
|
||||
.archive-chip.active {
|
||||
background: #DBE4FF;
|
||||
border-color: #DBE4FF;
|
||||
}
|
||||
|
||||
.archive-chip .chip-count {
|
||||
color: #8b93a7;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.archive-table {
|
||||
background: #fff;
|
||||
border: 1px solid #eef0f5;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.archive-table-head {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 40px minmax(0, 1fr) 180px 140px;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: 0 20px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
color: #8b93a7;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.archive-table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 40px minmax(0, 1fr) 180px 140px;
|
||||
align-items: center;
|
||||
min-height: 58px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.archive-table-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.archive-table-row:hover {
|
||||
background: #f8faff;
|
||||
}
|
||||
|
||||
.archive-select-cell,
|
||||
.head-col.select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.archive-type-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.archive-name-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.archive-item-title {
|
||||
color: #050841;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.archive-item-type {
|
||||
color: #8b93a7;
|
||||
font-size: 12px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.archive-date-cell {
|
||||
color: #8b93a7;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.archive-action-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.archive-restore-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
padding: 0 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #dfe3ec;
|
||||
border-radius: 999px;
|
||||
color: #3d5df6;
|
||||
font-family: var(--fmt-font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.archive-restore-btn:hover {
|
||||
background: #f1f5fe;
|
||||
border-color: #3d5df6;
|
||||
}
|
||||
|
||||
.archive-delete-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid #dfe3ec;
|
||||
background: white;
|
||||
color: #8b93a7;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.archive-delete-btn:hover {
|
||||
border-color: #fca5a5;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.workspace-pill-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #3c4679;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.workspace-pill-icon.ingredient,
|
||||
.workspace-pill-icon.purchase {
|
||||
background: #3f908a;
|
||||
}
|
||||
|
||||
.workspace-pill-icon.book {
|
||||
background: #f3a642;
|
||||
}
|
||||
|
||||
/* 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: #3d5df6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.batch-restore-btn:hover {
|
||||
background: #2b49db;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.archive-empty-state {
|
||||
text-align: center;
|
||||
padding: 64px 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #eef0f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.empty-icon-circle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 16px;
|
||||
background: #f1f5fe;
|
||||
border-radius: 50%;
|
||||
color: #3d5df6;
|
||||
}
|
||||
|
||||
.archive-back-btn {
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
padding: 8px 18px;
|
||||
background: #3d5df6;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,92 +2,276 @@
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import EntityDirectory from "../../../components/EntityDirectory";
|
||||
import WorkspaceFilterBar from "../../../components/WorkspaceFilterBar";
|
||||
import { TYPE_ICONS, TYPE_ICON_TRANSFORMS } from "../../../lib/icons";
|
||||
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,r.tags_json,r.categories_json,r.station,
|
||||
(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,i.tags_json,i.categories_json,
|
||||
(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 recipeItemRows = database.prepare("SELECT recipe_id, ingredient_id FROM recipe_items WHERE ingredient_id IS NOT NULL").all() as { recipe_id: string; ingredient_id: string }[];
|
||||
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 recipeIngredientsMap = new Map<string, Set<string>>();
|
||||
const ingredientRecipeCounts = new Map<string, number>();
|
||||
recipeItemRows.forEach(({ recipe_id, ingredient_id }) => {
|
||||
if (!recipeIngredientsMap.has(recipe_id)) {
|
||||
recipeIngredientsMap.set(recipe_id, new Set());
|
||||
}
|
||||
const set = recipeIngredientsMap.get(recipe_id)!;
|
||||
if (!set.has(ingredient_id)) {
|
||||
set.add(ingredient_id);
|
||||
ingredientRecipeCounts.set(ingredient_id, (ingredientRecipeCounts.get(ingredient_id) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const requested=Astro.url.searchParams.get("type");
|
||||
if (requested === "inventory") return Astro.redirect("/app/inventory/", 303);
|
||||
const query=(Astro.url.searchParams.get("q")??"").trim();
|
||||
const normalizedQuery=query.toLocaleLowerCase();
|
||||
const validSearchTypes=["recipe","ingredient","book","purchase"];
|
||||
const selectedSearchTypes=Astro.url.searchParams.getAll("item_type").filter((value)=>validSearchTypes.includes(value));
|
||||
const filteringSearchTypes=selectedSearchTypes.length>0;
|
||||
const attention=Astro.url.searchParams.get("attention")==="1", missingCost=Astro.url.searchParams.get("missing_cost")==="1", noUsda=Astro.url.searchParams.get("no_usda")==="1",unused=Astro.url.searchParams.get("unused")==="1",emptyRecipe=Astro.url.searchParams.get("empty_recipe")==="1",placeholderSteps=Astro.url.searchParams.get("placeholder_steps")==="1";
|
||||
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 selectedTags=Astro.url.searchParams.getAll("tag").map(t=>t.trim().toLowerCase()).filter(Boolean);
|
||||
const selectedIngredients=Astro.url.searchParams.getAll("ingredient").map(i=>i.trim().toLowerCase()).filter(Boolean);
|
||||
|
||||
// Default to "recipe" when not in a global search
|
||||
const type = ["recipe","ingredient","book","purchase"].includes(requested ?? "")
|
||||
? requested
|
||||
: (query ? undefined : "recipe");
|
||||
|
||||
// Recipe attention filters
|
||||
const emptyRecipe=Astro.url.searchParams.get("empty_recipe")==="1";
|
||||
const missingYield=Astro.url.searchParams.get("missing_yield")==="1";
|
||||
const placeholderSteps=Astro.url.searchParams.get("placeholder_steps")==="1";
|
||||
const attentionRecipe=Astro.url.searchParams.get("attention")==="1";
|
||||
const recipeFilterActive=emptyRecipe||missingYield||placeholderSteps||attentionRecipe;
|
||||
|
||||
// Ingredient attention filters
|
||||
const unused=Astro.url.searchParams.get("unused")==="1";
|
||||
const missingCost=Astro.url.searchParams.get("missing_cost")==="1";
|
||||
const noPurchase=Astro.url.searchParams.get("no_purchase")==="1" || Astro.url.searchParams.get("no_usda")==="1";
|
||||
const attentionIngredient=Astro.url.searchParams.get("attention")==="1";
|
||||
const ingredientFilterActive=unused||missingCost||noPurchase||attentionIngredient;
|
||||
|
||||
const recipeAttentionCounts = {
|
||||
empty: recipes.filter(r => r.item_count === 0).length,
|
||||
missingYield: recipes.filter(r => !r.yield_quantity || Number(r.yield_quantity) <= 0 || !r.yield_unit_id).length,
|
||||
placeholder: recipes.filter(r => r.placeholder_count > 0).length
|
||||
};
|
||||
|
||||
const ingredientAttentionCounts = {
|
||||
unused: ingredients.filter(i => i.recipe_count === 0).length,
|
||||
missingCost: ingredients.filter(i => i.price_count === 0).length,
|
||||
noPurchase: ingredients.filter(i => i.price_count === 0 || i.nutrition_count === 0).length
|
||||
};
|
||||
|
||||
const searchItemTypeCounts = {
|
||||
recipe: recipes.filter(item => `${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).length,
|
||||
ingredient: ingredients.filter(item => `${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).length,
|
||||
book: books.filter(item => `${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).length,
|
||||
purchase: purchases.filter(item => `${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).length
|
||||
};
|
||||
|
||||
function parseItemTags(tagsJson?: string, categoriesJson?: string): string[] {
|
||||
const set = new Set<string>();
|
||||
if (tagsJson) {
|
||||
try {
|
||||
const arr = JSON.parse(tagsJson);
|
||||
if (Array.isArray(arr)) arr.forEach((t: string) => t && set.add(String(t).trim().toLowerCase()));
|
||||
} catch {}
|
||||
}
|
||||
if (categoriesJson) {
|
||||
try {
|
||||
const arr = JSON.parse(categoriesJson);
|
||||
if (Array.isArray(arr)) arr.forEach((c: string) => c && set.add(String(c).trim().toLowerCase()));
|
||||
} catch {}
|
||||
}
|
||||
return Array.from(set);
|
||||
}
|
||||
|
||||
// Compute tag counts
|
||||
const activeDataset = type === "ingredient" ? ingredients : recipes;
|
||||
const tagCountsMap = new Map<string, number>();
|
||||
activeDataset.forEach((item) => {
|
||||
const tags = parseItemTags(item.tags_json, item.categories_json);
|
||||
tags.forEach((t) => {
|
||||
tagCountsMap.set(t, (tagCountsMap.get(t) || 0) + 1);
|
||||
});
|
||||
});
|
||||
|
||||
const tagOptions = Array.from(tagCountsMap.entries())
|
||||
.map(([tag, count]) => ({
|
||||
id: tag,
|
||||
name: titleCase(tag.replace(/_/g, " ")),
|
||||
count,
|
||||
checked: selectedTags.includes(tag)
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
||||
|
||||
const ingredientOptions = ingredients
|
||||
.filter(i => (ingredientRecipeCounts.get(i.id) || 0) > 0)
|
||||
.map(i => ({
|
||||
id: i.id,
|
||||
name: titleCase(i.name),
|
||||
count: ingredientRecipeCounts.get(i.id) || 0,
|
||||
checked: selectedIngredients.includes(i.id)
|
||||
}))
|
||||
.sort((a, b) => (b.count ?? 0) - (a.count ?? 0) || a.name.localeCompare(b.name));
|
||||
|
||||
const currentParams = {
|
||||
type: type ?? "",
|
||||
q: query,
|
||||
empty_recipe: Astro.url.searchParams.get("empty_recipe") ?? "",
|
||||
missing_yield: Astro.url.searchParams.get("missing_yield") ?? "",
|
||||
placeholder_steps: Astro.url.searchParams.get("placeholder_steps") ?? "",
|
||||
unused: Astro.url.searchParams.get("unused") ?? "",
|
||||
missing_cost: Astro.url.searchParams.get("missing_cost") ?? "",
|
||||
no_purchase: Astro.url.searchParams.get("no_purchase") ?? "",
|
||||
no_usda: Astro.url.searchParams.get("no_usda") ?? "",
|
||||
item_type: selectedSearchTypes,
|
||||
tag: selectedTags,
|
||||
ingredient: selectedIngredients
|
||||
};
|
||||
|
||||
const filteredIngredients=ingredients.filter((ingredient)=>{
|
||||
if (ingredientFilterActive) {
|
||||
const conditions = [
|
||||
unused && ingredient.recipe_count === 0,
|
||||
missingCost && ingredient.price_count === 0,
|
||||
noPurchase && (ingredient.price_count === 0 || ingredient.nutrition_count === 0)
|
||||
];
|
||||
const matchesAttention = (unused || missingCost || noPurchase)
|
||||
? conditions.some(Boolean)
|
||||
: (ingredient.recipe_count === 0 || ingredient.price_count === 0 || ingredient.nutrition_count === 0);
|
||||
if (!matchesAttention) return false;
|
||||
}
|
||||
|
||||
if (selectedTags.length > 0) {
|
||||
const tags = parseItemTags(ingredient.tags_json, ingredient.categories_json);
|
||||
const matchesTags = selectedTags.some(t => tags.includes(t));
|
||||
if (!matchesTags) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const filteredRecipes=recipes.filter((recipe)=>{
|
||||
if (recipeFilterActive) {
|
||||
const isMissingYield = !recipe.yield_quantity || Number(recipe.yield_quantity) <= 0 || !recipe.yield_unit_id;
|
||||
const conditions = [
|
||||
emptyRecipe && recipe.item_count === 0,
|
||||
missingYield && isMissingYield,
|
||||
placeholderSteps && recipe.placeholder_count > 0
|
||||
];
|
||||
const matchesAttention = (emptyRecipe || missingYield || placeholderSteps)
|
||||
? conditions.some(Boolean)
|
||||
: (recipe.item_count === 0 || isMissingYield || recipe.placeholder_count > 0);
|
||||
if (!matchesAttention) return false;
|
||||
}
|
||||
|
||||
if (selectedTags.length > 0) {
|
||||
const tags = parseItemTags(recipe.tags_json, recipe.categories_json);
|
||||
const matchesTags = selectedTags.some(t => tags.includes(t));
|
||||
if (!matchesTags) return false;
|
||||
}
|
||||
|
||||
if (selectedIngredients.length > 0) {
|
||||
const ingIds = recipeIngredientsMap.get(recipe.id) ?? new Set();
|
||||
const matchesIngredients = selectedIngredients.some(id => ingIds.has(id));
|
||||
if (!matchesIngredients) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
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) && (selectedTags.length === 0 || parseItemTags(item.tags_json, item.categories_json).some(t => selectedTags.includes(t))) && (selectedIngredients.length === 0 || Array.from(recipeIngredientsMap.get(item.id) ?? []).some((id: string) => selectedIngredients.includes(id)))).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) && (selectedTags.length === 0 || parseItemTags(item.tags_json, item.categories_json).some(t => selectedTags.includes(t)))).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) && selectedTags.length === 0).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) && selectedTags.length === 0).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>}
|
||||
{selectedTags.map((tag)=><input type="hidden" name="tag" value={tag}/>)}
|
||||
{selectedIngredients.map((ing)=><input type="hidden" name="ingredient" value={ing}/>)}
|
||||
<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}/>
|
||||
<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>
|
||||
</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>}
|
||||
</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>:<>
|
||||
<section class="shell directory-workspace">
|
||||
<div class="workspace-nav-block">
|
||||
{!readOnlyMode&&<nav class="workspace-utility-nav" aria-label="Secondary tools">
|
||||
<a class="workspace-utility-link" href="/app/archive/">Archive</a>
|
||||
<a class="workspace-utility-link" href="/app/settings/">Data Management</a>
|
||||
</nav>}
|
||||
<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>)}
|
||||
<WorkspaceFilterBar
|
||||
client:load
|
||||
type={type ?? undefined}
|
||||
query={query}
|
||||
recipeCounts={recipeAttentionCounts}
|
||||
ingredientCounts={ingredientAttentionCounts}
|
||||
itemTypeCounts={searchItemTypeCounts}
|
||||
tagOptions={tagOptions}
|
||||
ingredientOptions={ingredientOptions}
|
||||
currentParams={currentParams}
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
{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,9 +4,10 @@ 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";
|
||||
import { getTabIconHtml, type TabIconKey } from "../../../../lib/icons";
|
||||
const id = Astro.params.id!;
|
||||
const editing = !readOnlyMode && Astro.url.searchParams.get("edit") === "1";
|
||||
const database = openDatabase({ readOnly: false });
|
||||
@@ -127,28 +128,326 @@ const prepDisplay = prep.map((row) => {
|
||||
};
|
||||
return {...row,weight:showMeasure("mass"),volume:showMeasure("volume"),each:showMeasure("count")};
|
||||
});
|
||||
const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
|
||||
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" type="button" data-tab="overview"><Fragment set:html={recipeTabIcon("overview")}/><span class="recipe-tab-label">Details</span></button>
|
||||
<button class="active" 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 +484,27 @@ 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');
|
||||
} else {
|
||||
switchIngredientTab('costs');
|
||||
}
|
||||
</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(--fmt-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(--fmt-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(--fmt-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(--fmt-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(--fmt-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(--fmt-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,236 @@
|
||||
---
|
||||
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";
|
||||
import { TYPE_ICONS, TYPE_ICON_TRANSFORMS } from "../../../../lib/icons";
|
||||
|
||||
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={TYPE_ICONS.recipe} style={TYPE_ICON_TRANSFORMS.recipe ? { transform: TYPE_ICON_TRANSFORMS.recipe } : undefined}/>
|
||||
</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={TYPE_ICONS.recipe} style={TYPE_ICON_TRANSFORMS.recipe ? { transform: TYPE_ICON_TRANSFORMS.recipe } : undefined}/>
|
||||
</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,16 +1,24 @@
|
||||
---
|
||||
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";
|
||||
import { getTabIconHtml, type TabIconKey } from "../../../../lib/icons";
|
||||
|
||||
const id = Astro.params.id!;
|
||||
const editing = !readOnlyMode && Astro.url.searchParams.get("edit") === "1";
|
||||
@@ -21,22 +29,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 +51,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 +130,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 +169,320 @@ 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 recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey);
|
||||
---
|
||||
<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} shelfLife={shelfLife}/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="recipe-read-right">
|
||||
<div class="recipe-workspace-tabs recipe-read-tabs">
|
||||
<button class="mobile-only-tab" type="button" data-recipe-tab="formula"><Fragment set:html={recipeTabIcon("formula")}/><span class="recipe-tab-label">Ingredients</span></button>
|
||||
<button class="active" 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.
|
||||
<span class="uom-help-wrap">
|
||||
<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>
|
||||
<span class="uom-tooltip-popper fmt-tooltip-card" role="tooltip">
|
||||
For example, if your recipe yields 1 quart, weighs 850 grams, and each portion is 85 grams, toggle off the standard conversion and enter '850 grams = 1 quart = 10 portions'.
|
||||
</span>
|
||||
</span>
|
||||
</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');
|
||||
} else {
|
||||
switchRecipeTab('method');
|
||||
}
|
||||
</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,696 @@
|
||||
---
|
||||
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;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-page :global(.detail-utility) {
|
||||
position: sticky !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100% !important;
|
||||
z-index: 30 !important;
|
||||
background: #ffffff !important;
|
||||
border-bottom: 1px solid #edf0f5 !important;
|
||||
}
|
||||
|
||||
.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,95 @@
|
||||
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-avatar" href="/app/" aria-label="Recipe Book home">RB</a>
|
||||
<a class="detail-back" href={resolvedHref} aria-label={`Back to ${resolvedSection??"home"}`} data-back-link>‹</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,10 @@
|
||||
import { useRef,useState } from "preact/hooks";
|
||||
import { TYPE_ICONS, TYPE_ICON_TRANSFORMS } from "../lib/icons";
|
||||
|
||||
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 };
|
||||
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 +13,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>;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
TAB_ICONS,
|
||||
TAB_VIEWBOXES,
|
||||
TYPE_ICONS,
|
||||
TYPE_ICON_TRANSFORMS,
|
||||
UI_ICONS,
|
||||
type TabIconKey,
|
||||
type EntityIconKey,
|
||||
type UiIconKey
|
||||
} from "../lib/icons";
|
||||
|
||||
interface TabIconProps {
|
||||
name: TabIconKey;
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function TabIcon({ name, className = "recipe-tab-icon", size }: TabIconProps) {
|
||||
const path = TAB_ICONS[name];
|
||||
const viewBox = TAB_VIEWBOXES[name] || "0 0 24 24";
|
||||
if (!path) return null;
|
||||
|
||||
return (
|
||||
<span class={className} aria-hidden="true">
|
||||
<svg viewBox={viewBox} width={size} height={size} aria-hidden="true">
|
||||
<path fill="currentColor" d={path} />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface TypeIconProps {
|
||||
kind: EntityIconKey;
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function TypeIcon({ kind, className, size = 20 }: TypeIconProps) {
|
||||
const path = TYPE_ICONS[kind];
|
||||
const transform = TYPE_ICON_TRANSFORMS[kind];
|
||||
if (!path) return null;
|
||||
|
||||
const wrapperClass = className ?? `workspace-pill-icon ${kind}`;
|
||||
|
||||
return (
|
||||
<span class={wrapperClass} aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true">
|
||||
<path fill="currentColor" d={path} style={transform ? { transform } : undefined} />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface UiIconProps {
|
||||
name: UiIconKey;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UiIcon({ name, size = 16, className }: UiIconProps) {
|
||||
const path = UI_ICONS[name];
|
||||
if (!path) return null;
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} class={className} aria-hidden="true">
|
||||
<path fill="currentColor" d={path} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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,9 +2,10 @@ 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";
|
||||
import { TYPE_ICONS, TYPE_ICON_TRANSFORMS } from "../lib/icons";
|
||||
|
||||
type Props = {
|
||||
components: CalculatorComponent[];
|
||||
@@ -17,7 +18,9 @@ type Props = {
|
||||
cost: CostResult;
|
||||
servings?: number;
|
||||
showDerived?: boolean;
|
||||
showPercentControls?: boolean;
|
||||
yieldConversions?: CalculatorItem["measureConversions"];
|
||||
shelfLife?: string | { duration?: { quantity?: number; unit_id?: string }; storage_condition?: string };
|
||||
};
|
||||
|
||||
function itemUnits(item: CalculatorItem, units: Record<string, Unit>) {
|
||||
@@ -46,13 +49,94 @@ 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) {
|
||||
const SCALE_OPTIONS = [
|
||||
{ value: 0.5, label: "1/2x" },
|
||||
{ value: 1, label: "1x" },
|
||||
{ value: 2, label: "2x" },
|
||||
{ value: 3, label: "3x" },
|
||||
{ value: 4, label: "4x" },
|
||||
{ value: 5, label: "5x" },
|
||||
];
|
||||
|
||||
export default function RecipeCalculator({ components, units, basisUnitId, yieldQuantity, yieldUnitId, nutrition, cost, servings, showDerived = true, showPercentControls = true, yieldConversions = [], shelfLife }: Props) {
|
||||
const [factor, setFactor] = useState(1);
|
||||
const [calculatePercent,setCalculatePercent]=useState(true);
|
||||
const [isCustomScale, setIsCustomScale] = useState(false);
|
||||
const [calculatePercent,setCalculatePercent]=useState(showPercentControls);
|
||||
const [percentMode,setPercentMode]=useState<"standard"|"bakers">("standard");
|
||||
const [yieldDisplayUnitId, setYieldDisplayUnitId] = useState(yieldUnitId);
|
||||
const [lineUnits, setLineUnits] = useState<Record<string, string>>({});
|
||||
const [showScaleTooltip, setShowScaleTooltip] = useState(false);
|
||||
const [scaleTooltipTimer, setScaleTooltipTimer] = useState<any>(null);
|
||||
const [activeAttentionId, setActiveAttentionId] = useState<string | null>(null);
|
||||
const validFactor = Number.isFinite(factor) && factor >= 0 ? factor : 0;
|
||||
|
||||
const matchedScaleOption = SCALE_OPTIONS.find((opt) => Math.abs(opt.value - factor) < 0.001);
|
||||
const showCustomInput = isCustomScale || !matchedScaleOption;
|
||||
|
||||
const handleScaleSelectChange = (e: Event) => {
|
||||
const val = (e.currentTarget as HTMLSelectElement).value;
|
||||
if (val === "custom") {
|
||||
setIsCustomScale(true);
|
||||
} else {
|
||||
setIsCustomScale(false);
|
||||
setFactor(Number(val));
|
||||
}
|
||||
};
|
||||
|
||||
const handleScaleTooltipEnter = () => {
|
||||
if (scaleTooltipTimer) clearTimeout(scaleTooltipTimer);
|
||||
setShowScaleTooltip(true);
|
||||
};
|
||||
|
||||
const handleScaleTooltipLeave = () => {
|
||||
const timer = setTimeout(() => {
|
||||
setShowScaleTooltip(false);
|
||||
}, 150);
|
||||
setScaleTooltipTimer(timer);
|
||||
};
|
||||
|
||||
const handleScaleTooltipToggle = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowScaleTooltip((prev) => !prev);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target?.closest(".helper-tooltip-wrap")) {
|
||||
setShowScaleTooltip(false);
|
||||
}
|
||||
if (!target?.closest(".ingredient-attention-wrapper")) {
|
||||
setActiveAttentionId(null);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setShowScaleTooltip(false);
|
||||
setActiveAttentionId(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shelfLifeText = useMemo(() => {
|
||||
if (!shelfLife) return null;
|
||||
if (typeof shelfLife === "string") return shelfLife.trim() || null;
|
||||
const q = shelfLife.duration?.quantity;
|
||||
const u = shelfLife.duration?.unit_id;
|
||||
if (q != null && u) {
|
||||
const unitLabel = q === 1 ? u : `${u}s`;
|
||||
return `${q} ${unitLabel}`;
|
||||
}
|
||||
return null;
|
||||
}, [shelfLife]);
|
||||
|
||||
useEffect(() => { window.dispatchEvent(new CustomEvent("recipe-scale-change", { detail:validFactor })); }, [validFactor]);
|
||||
const itemWeight=(item:CalculatorItem)=>{try{return convertItem(item.amount.quantity,item.amount.unit_id,"gram",item,units);}catch{return undefined;}};
|
||||
const total = useMemo(
|
||||
@@ -63,51 +147,168 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
|
||||
[components, validFactor, basisUnitId, units],
|
||||
);
|
||||
const unconvertedCount=useMemo(()=>components.flatMap((component)=>component.items).filter((item)=>{try{convertItem(item.amount.quantity,item.amount.unit_id,basisUnitId,item,units);return false;}catch{return true;}}).length,[components,basisUnitId,units]);
|
||||
const percentageBase=components.flatMap(component=>component.items).filter(item=>percentMode==="standard"||item.basisMember).reduce((sum,item)=>sum+(itemWeight(item)??0),0);
|
||||
const percentageBase=useMemo(()=>{if(!calculatePercent)return 0;if(percentMode==="standard")return total;return components.flatMap(c=>c.items).filter(item=>item.basisMember).reduce((sum,item)=>{const w=itemWeight(item);return w!=null?sum+w*validFactor:sum;},0);},[calculatePercent,percentMode,total,components,units,validFactor]);
|
||||
const baseItems=components.flatMap(component=>component.items).filter(item=>item.basisMember);
|
||||
const basisUnit = units[basisUnitId];
|
||||
const yieldItem = { id:"yield", label:"Yield", amount:{quantity:yieldQuantity,unit_id:yieldUnitId}, measureConversions:yieldConversions } as CalculatorItem;
|
||||
const yieldUnits = itemUnits(yieldItem,units);
|
||||
const scaledYield = convertItem(yieldQuantity * validFactor,yieldUnitId,yieldDisplayUnitId,yieldItem,units);
|
||||
const changeYield = (value: number) => {
|
||||
const canonicalQuantity = convertItem(value,yieldDisplayUnitId,yieldUnitId,yieldItem,units);
|
||||
setFactor(yieldQuantity > 0 ? canonicalQuantity / yieldQuantity : 1);
|
||||
const yieldUnits = useMemo(() => itemUnits(yieldItem,units), [yieldItem, units]);
|
||||
const scaledYield = useMemo(() => {
|
||||
try { return convertItem(yieldQuantity * validFactor, yieldUnitId, yieldDisplayUnitId, yieldItem, units); }
|
||||
catch { return yieldQuantity * validFactor; }
|
||||
}, [yieldQuantity, validFactor, yieldUnitId, yieldDisplayUnitId, yieldItem, units]);
|
||||
|
||||
const changeYield = (nextYield: number) => {
|
||||
if (!Number.isFinite(nextYield) || nextYield <= 0 || yieldQuantity <= 0) return;
|
||||
try {
|
||||
const canonicalNextYield = convertItem(nextYield, yieldDisplayUnitId, yieldUnitId, yieldItem, units);
|
||||
setFactor(canonicalNextYield / yieldQuantity);
|
||||
} catch {
|
||||
setFactor(nextYield / yieldQuantity);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
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>
|
||||
<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>
|
||||
</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>
|
||||
const currentScaleLabel = !showCustomInput && matchedScaleOption ? matchedScaleOption.label : "Custom";
|
||||
const yieldUnitSymbol = yieldUnits.find((u) => u.id === yieldDisplayUnitId)?.symbol ?? yieldDisplayUnitId;
|
||||
const yieldQtyString = String(roundForDisplay(scaledYield));
|
||||
const customFactorString = String(roundForDisplay(factor));
|
||||
|
||||
{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>}
|
||||
return (
|
||||
<section class="calculator" aria-label="Recipe scaling calculator">
|
||||
<div class="calculator-heading" data-testid="metaItem">
|
||||
<div class="basis-input batch-multiplier-field">
|
||||
<span class="scale-label">Batch Size:</span>
|
||||
<span class="recipe-scale-control batch-size-control">
|
||||
<select
|
||||
aria-label="Batch size multiplier"
|
||||
class="fmt-scale-select"
|
||||
data-testid="ScaleSelectorBase_scale_select"
|
||||
style={{ width: `${currentScaleLabel.length + 2.4}ch` }}
|
||||
value={!showCustomInput && matchedScaleOption ? String(matchedScaleOption.value) : "custom"}
|
||||
onChange={handleScaleSelectChange}
|
||||
>
|
||||
{SCALE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={String(opt.value)}>{opt.label}</option>
|
||||
))}
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
{showCustomInput && (
|
||||
<span class="custom-scale-wrap">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
aria-label="Custom batch multiplier"
|
||||
class="custom-scale-input"
|
||||
style={{ width: `${Math.max(2, customFactorString.length + 0.8)}ch` }}
|
||||
value={customFactorString}
|
||||
onFocus={(event) => (event.currentTarget as HTMLInputElement).select()}
|
||||
onClick={(event) => (event.currentTarget as HTMLInputElement).select()}
|
||||
onInput={(event) => {
|
||||
const val = Number((event.currentTarget as HTMLInputElement).value);
|
||||
if (!isNaN(val) && val > 0) setFactor(val);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<span class="batch-unit-suffix">x</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="basis-input finished-yield-field">
|
||||
<span class="scale-label">Yield:</span>
|
||||
<span class="recipe-scale-control quantity-control">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
aria-label="Finished yield quantity"
|
||||
style={{ width: `${Math.max(2, yieldQtyString.length + 0.5)}ch` }}
|
||||
value={yieldQtyString}
|
||||
onFocus={(event) => (event.currentTarget as HTMLInputElement).select()}
|
||||
onClick={(event) => (event.currentTarget as HTMLInputElement).select()}
|
||||
onInput={(event) => changeYield(Number((event.currentTarget as HTMLInputElement).value))}
|
||||
/>
|
||||
<select
|
||||
aria-label="Finished yield unit"
|
||||
value={yieldDisplayUnitId}
|
||||
style={{ width: `${yieldUnitSymbol.length + 0.8}ch` }}
|
||||
onChange={(event) => setYieldDisplayUnitId((event.currentTarget as HTMLSelectElement).value)}
|
||||
>
|
||||
{yieldUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}
|
||||
</select>
|
||||
</span>
|
||||
<span
|
||||
class="helper-tooltip-wrap"
|
||||
data-testid="HelperTextTooltipBase"
|
||||
onMouseEnter={handleScaleTooltipEnter}
|
||||
onMouseLeave={handleScaleTooltipLeave}
|
||||
>
|
||||
<button
|
||||
class="HelperTooltipBase_help_icon"
|
||||
type="button"
|
||||
data-testid="HelperTooltipBase_help_icon"
|
||||
aria-label="Yield calculation help"
|
||||
aria-expanded={showScaleTooltip}
|
||||
onClick={handleScaleTooltipToggle}
|
||||
>
|
||||
<svg class="helper-tooltip-svg" viewBox="-1 0 20 18" width="18" height="18" fill="none" data-testid="TooltipWithIcon_IconDefault">
|
||||
<path d="M17.376 17.8138C17.3808 17.7971 17.3855 17.7806 17.3862 17.7631C17.3865 17.7589 17.3887 17.7554 17.3887 17.7512C17.3887 17.7377 17.3833 17.7262 17.3813 17.7137C17.3793 17.7012 17.3813 17.689 17.3773 17.6768L14.2885 7.8178C14.2873 7.8138 14.284 7.81131 14.283 7.80731C14.2768 7.79059 14.2673 7.77611 14.2576 7.76114C14.2496 7.74816 14.2426 7.73493 14.2324 7.72395C14.2219 7.71272 14.2092 7.70448 14.1967 7.6955C14.1832 7.68526 14.1707 7.67478 14.1557 7.66779C14.143 7.6618 14.1293 7.65955 14.1153 7.65581C14.0976 7.65082 14.0801 7.64583 14.0614 7.64508C14.0574 7.64483 14.0542 7.64283 14.0502 7.64283H11.9598V5.60864C14.2521 4.65244 16.0574 2.69088 16.8052 0.324972C16.8092 0.312492 16.8072 0.300262 16.8092 0.288032C16.8114 0.275053 16.8169 0.263322 16.8169 0.249844C16.8169 0.245851 16.8147 0.242356 16.8144 0.238363C16.8137 0.220392 16.8089 0.204168 16.8042 0.187196C16.8002 0.17247 16.798 0.157744 16.7915 0.144266C16.7847 0.129789 16.7745 0.118058 16.765 0.10483C16.7555 0.0921004 16.7475 0.0788719 16.7358 0.0678898C16.7248 0.0576564 16.7111 0.0509174 16.6986 0.0426808C16.6836 0.0331962 16.6699 0.0237114 16.6529 0.0174715C16.6492 0.015974 16.6467 0.0129789 16.6427 0.0117309C16.6297 0.00748782 16.617 0.00948452 16.6043 0.00748777C16.5918 0.00549101 16.5806 0 16.5676 0H0.820666C0.806939 0 0.795208 0.00549111 0.782478 0.00773746C0.769999 0.00973422 0.757519 0.00773741 0.745289 0.0117309C0.741545 0.0129789 0.739049 0.0159741 0.735305 0.0172221C0.718333 0.0234619 0.704106 0.0331962 0.68913 0.0426808C0.676401 0.0509174 0.663172 0.0574067 0.65244 0.0676401C0.640709 0.0786222 0.632223 0.0921005 0.622738 0.105079C0.613253 0.117809 0.60327 0.129789 0.596531 0.144016C0.590041 0.157494 0.587795 0.17222 0.583801 0.186946C0.579059 0.203919 0.574317 0.220392 0.573318 0.238113C0.573069 0.242107 0.571072 0.245351 0.571072 0.249345C0.571072 0.262573 0.576563 0.274304 0.57856 0.287033C0.580557 0.299763 0.57856 0.312243 0.582803 0.324722C1.34432 2.72507 3.10645 4.63946 5.42818 5.60589V7.64233H3.33882C3.33458 7.64233 3.33134 7.64458 3.32709 7.64458C3.30887 7.64533 3.29215 7.65032 3.27468 7.65506C3.26045 7.65905 3.24622 7.6613 3.23325 7.66729C3.21802 7.67453 3.20554 7.68501 3.19206 7.695C3.17983 7.70423 3.1671 7.71197 3.15662 7.7232C3.14614 7.73418 3.1394 7.74791 3.13091 7.76089C3.12168 7.77586 3.11219 7.78984 3.10595 7.80656C3.10445 7.81031 3.10146 7.81305 3.10021 7.81705L0.0114815 17.676C0.00748795 17.6885 0.00948452 17.7007 0.00748777 17.713C0.00549101 17.7257 0 17.7372 0 17.7504C0 17.7547 0.00224641 17.7581 0.002496 17.7624C0.00324478 17.7801 0.00823653 17.7963 0.0127292 17.8133C0.0167227 17.828 0.0192187 17.8428 0.0257081 17.8565C0.0324472 17.871 0.0424311 17.8824 0.0519157 17.8954C0.0614003 17.9084 0.0696368 17.9216 0.0816173 17.9326C0.0923498 17.9426 0.105578 17.9491 0.118308 17.9573C0.133283 17.9668 0.14776 17.9765 0.164732 17.983C0.168476 17.9843 0.171222 17.9875 0.175215 17.9885C0.199925 17.9963 0.224884 17.9998 0.249345 18H17.1387C17.1634 18 17.1883 17.9963 17.2133 17.9885C17.2173 17.9873 17.2198 17.9843 17.2235 17.983C17.2407 17.9768 17.2552 17.9671 17.2704 17.9573C17.2829 17.9493 17.2961 17.9426 17.3069 17.9326C17.3189 17.9216 17.3273 17.9081 17.3368 17.8952C17.3461 17.8822 17.3563 17.8707 17.3628 17.8565C17.3698 17.8435 17.3723 17.8285 17.376 17.8138ZM1.17185 0.498939H16.2166C15.4372 2.63023 13.7414 4.37739 11.6196 5.21103C11.4731 5.26819 11.3136 5.32435 11.1446 5.37801C9.57419 5.88544 7.82004 5.88644 6.2441 5.37751C6.07987 5.32585 5.92487 5.27094 5.76937 5.20879C3.61587 4.36191 1.95807 2.65568 1.17185 0.498939ZM5.92737 5.79433C5.98278 5.8133 6.03594 5.83527 6.09285 5.85299C6.92799 6.1228 7.80357 6.25933 8.69537 6.25933C9.58742 6.25933 10.463 6.12255 11.2969 5.85324C11.3531 5.83552 11.4065 5.8168 11.4609 5.79833V7.64208H5.92737V5.79433Z" fill="currentColor"/>
|
||||
<path d="M12.3787 12.9467C12.3787 14.9766 10.7271 16.6282 8.69714 16.6282C6.66719 16.6282 5.01562 14.9766 5.01562 12.9467C5.01562 10.9167 6.66719 9.26514 8.69714 9.26514C10.7271 9.26514 12.3787 10.9167 12.3787 12.9467Z" fill="white"/>
|
||||
<path d="M8.69464 13.1962C8.75479 13.1962 8.81519 13.1745 8.86337 13.1303L10.6312 11.5079C10.7328 11.4148 10.7398 11.2568 10.6465 11.1555C10.5534 11.0537 10.3949 11.0469 10.294 11.1403L8.52616 12.7626C8.42458 12.8557 8.41784 13.0137 8.51094 13.1151C8.55986 13.1687 8.62725 13.1962 8.69464 13.1962Z" fill="currentColor"/>
|
||||
<path d="M8.81381 6.49281C13.1681 6.49281 16.6979 3.58588 16.6979 0H0.929688C0.929688 3.58588 4.45953 6.49281 8.81381 6.49281Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
{showScaleTooltip && (
|
||||
<div class="helper-tooltip-popper fmt-tooltip-card" role="tooltip">
|
||||
<div class="tooltip-section">
|
||||
<b>Ways to scale your recipe:</b>
|
||||
<ul>
|
||||
<li>Click on an ingredient's quantity and update it</li>
|
||||
<li>Click on the recipe's <b>Total Yield</b> and update it</li>
|
||||
<li>Change the <b>Batch Size</b> of the recipe</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tooltip-section">
|
||||
<b>How to convert units of measure:</b>
|
||||
<ul>
|
||||
<li>Click on an ingredient's unit of measure & change to the desired unit - the quantity will adjust automatically</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{shelfLifeText && (
|
||||
<div class="basis-input shelf-life-field">
|
||||
<span class="scale-label">Shelf Life:</span>
|
||||
<span class="shelf-life-value">{shelfLifeText}</span>
|
||||
</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>
|
||||
|
||||
{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="text" inputMode="decimal" style={{ width: `${Math.max(2, String(roundForDisplay(displayQuantity)).length + 0.5)}ch` }} value={roundForDisplay(displayQuantity)} onFocus={(event) => (event.currentTarget as HTMLInputElement).select()} onClick={(event) => (event.currentTarget as HTMLInputElement).select()} 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 +320,42 @@ 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="text" inputMode="decimal" style={{ width: `${Math.max(2, String(roundForDisplay(displayQuantity)).length + 0.5)}ch` }} value={roundForDisplay(displayQuantity)} onFocus={(event) => (event.currentTarget as HTMLInputElement).select()} onClick={(event) => (event.currentTarget as HTMLInputElement).select()} 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-wrapper"
|
||||
onMouseEnter={() => setActiveAttentionId(item.id)}
|
||||
onMouseLeave={() => setActiveAttentionId((prev) => (prev === item.id ? null : prev))}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ingredient-attention-icon"
|
||||
aria-label={item.attentionMessage ?? "Needs attention"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setActiveAttentionId((prev) => (prev === item.id ? null : item.id));
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
{activeAttentionId === item.id && (
|
||||
<div class="ingredient-attention-popper fmt-tooltip-card" role="tooltip">
|
||||
<p>{item.attentionMessage ?? "No conversion equivalency available for this unit."}</p>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@@ -146,7 +376,34 @@ export function DerivedValues({ nutrition, cost, servings, factor }: { nutrition
|
||||
return <div class="derived-grid">
|
||||
<div id="nutrition"><NutritionPanel nutrition={nutrition} servings={servings} factor={factor} /></div>
|
||||
<section id="costing" class="derived-card" aria-labelledby="scaled-cost-heading">
|
||||
<div class="derived-title"><div><p class="eyebrow">Derived estimate</p><h3 id="scaled-cost-heading">Recipe cost</h3></div><strong>{Math.round(cost.completeness * 100)}% priced</strong></div>
|
||||
<div class="derived-title">
|
||||
<div>
|
||||
<p class="eyebrow">Derived estimate</p>
|
||||
<h3 id="scaled-cost-heading">Recipe cost</h3>
|
||||
</div>
|
||||
<div class="derived-cost-status">
|
||||
<strong>{Math.round(cost.completeness * 100)}% priced</strong>
|
||||
{cost.completeness < 1 && (
|
||||
<span class="cost-help-wrap">
|
||||
<button class="cost-help-btn" type="button" aria-label="Cost calculation details">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" 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 2M12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="cost-tooltip-popper fmt-tooltip-card" role="tooltip">
|
||||
<b>There is not enough information to calculate cost. Please verify the below:</b>
|
||||
<ul>
|
||||
<li>This recipe and all its sub-recipes must include Total Yield</li>
|
||||
<li>All ingredients in this recipe and its sub-recipes must be defined</li>
|
||||
<li>All ingredients in this recipe and its sub-recipes must have a purchase cost</li>
|
||||
<li>All ingredients in this recipe and its sub-recipes must have the needed unit conversion to convert from the unit in the recipe to the purchase unit</li>
|
||||
<li>If this recipe exists in multiple concepts or locations, there is a cost at each concept/location</li>
|
||||
</ul>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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)}</dd></div>}
|
||||
@@ -169,10 +426,157 @@ 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}`} aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d={TYPE_ICONS[line.kind as "recipe" | "ingredient"] ?? TYPE_ICONS.ingredient}
|
||||
style={TYPE_ICON_TRANSFORMS[line.kind as "recipe" | "ingredient"] ? { transform: TYPE_ICON_TRANSFORMS[line.kind as "recipe" | "ingredient"] } : undefined}
|
||||
/>
|
||||
</svg>
|
||||
</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-line-help-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="cost-attention-badge"
|
||||
aria-label="Cost information is incomplete"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" 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 2M12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="cost-line-tooltip fmt-tooltip-card" role="tooltip">
|
||||
{line.purchase ? "Cost information is incomplete" : "This ingredient is not yet set with a purchase cost and unit"}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span class="cost-line-value">
|
||||
{editable && 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 class="cost-ledger-header">
|
||||
<div class="cost-title-row">
|
||||
<h2>Recipe Cost</h2>
|
||||
{cost.completeness < 1 && (
|
||||
<span class="cost-help-wrap">
|
||||
<button class="cost-help-btn" type="button" aria-label="Cost calculation help">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" 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 2M12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="cost-tooltip-popper fmt-tooltip-card" role="tooltip">
|
||||
<b>There is not enough information to calculate cost. Please verify the below:</b>
|
||||
<ul>
|
||||
<li>This recipe and all its sub-recipes must include Total Yield</li>
|
||||
<li>All ingredients in this recipe and its sub-recipes must be defined</li>
|
||||
<li>All ingredients in this recipe and its sub-recipes must have a purchase cost</li>
|
||||
<li>All ingredients in this recipe and its sub-recipes must have the needed unit conversion to convert from the unit in the recipe to the purchase unit</li>
|
||||
<li>If this recipe exists in multiple concepts or locations, there is a cost at each concept/location</li>
|
||||
</ul>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<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-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-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,620 @@
|
||||
import { useState, useEffect, useRef } from "preact/hooks";
|
||||
import { createPortal } from "preact/compat";
|
||||
|
||||
export interface FilterOption {
|
||||
id: string;
|
||||
name: string;
|
||||
count?: number;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
export interface FilterCategoryConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
iconViewBox?: string;
|
||||
options: FilterOption[];
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
type?: string;
|
||||
query?: string;
|
||||
recipeCounts: {
|
||||
empty: number;
|
||||
missingYield: number;
|
||||
placeholder: number;
|
||||
};
|
||||
ingredientCounts: {
|
||||
unused: number;
|
||||
missingCost: number;
|
||||
noPurchase: number;
|
||||
};
|
||||
itemTypeCounts?: {
|
||||
recipe: number;
|
||||
ingredient: number;
|
||||
book: number;
|
||||
purchase: number;
|
||||
};
|
||||
tagOptions?: FilterOption[];
|
||||
ingredientOptions?: FilterOption[];
|
||||
currentParams: Record<string, string | string[]>;
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
filter: [
|
||||
"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",
|
||||
"M11.9799 15.0803C11.9799 13.8763 11.1671 12.8529 10.0534 12.5218L10.0534 0.752512C10.0534 0.301005 9.72232 -9.86801e-08 9.30091 -1.171e-07C8.87951 -1.35521e-07 8.5484 0.331105 8.5484 0.752512L8.5484 12.5218C7.43468 12.8228 6.62197 13.8763 6.62197 15.0803C6.62197 16.3145 7.43468 17.3379 8.5484 17.669L8.5484 19.7459C8.5484 20.1673 8.87951 20.4984 9.30091 20.4984C9.72232 20.4984 10.0534 20.1673 10.0534 19.7459L10.0534 17.669C11.1671 17.3379 11.9799 16.3145 11.9799 15.0803ZM10.4748 15.1104C10.4748 15.7426 9.96312 16.2844 9.30091 16.2844C8.6387 16.2844 8.12699 15.7426 8.12699 15.1104C8.12699 14.4783 8.6387 13.9365 9.30091 13.9365C9.96312 13.9365 10.4748 14.4482 10.4748 15.1104Z",
|
||||
"M5.35791 9.63216C5.35791 8.42814 4.5452 7.40472 3.43148 7.07362L3.43148 0.752512C3.43148 0.301005 3.10037 -9.86801e-08 2.67897 -1.171e-07C2.25756 -1.35521e-07 1.92645 0.331105 1.92645 0.752512L1.92645 7.10372C0.812735 7.40472 2.06111e-05 8.45824 2.05585e-05 9.66226C2.05045e-05 10.8964 0.812735 11.9198 1.92645 12.2509L1.92645 19.7459C1.92645 20.1673 2.25756 20.4984 2.67896 20.4984C3.10037 20.4984 3.43148 20.1673 3.43148 19.7459L3.43148 12.2208C4.5452 11.8897 5.35791 10.8663 5.35791 9.63216ZM3.85289 9.66226C3.85289 10.2944 3.34118 10.8362 2.67897 10.8362C2.01675 10.8362 1.50505 10.2944 1.50505 9.66226C1.50505 9.03015 2.01675 8.48834 2.67897 8.48834C3.34118 8.51844 3.85289 9.03015 3.85289 9.66226Z"
|
||||
],
|
||||
attention: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m1 15h-2v-2h2zm0-4h-2V7h2z",
|
||||
person: "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4m0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4",
|
||||
ingredient: "M13.2445 5.63676C12.3794 3.69079 10.9321 2.34779 8.61591 2.62707L7.52332 2.75825L7.6345 3.90089C7.76967 5.28511 8.82346 6.49035 9.62045 7.10812C8.82346 6.94913 8.19509 7.1927 7.94758 7.30796C6.76598 7.85825 5.83492 8.52233 5.08642 9.67906C3.97889 11.3907 3.82218 13.6224 4.83851 16.3608C6.38221 20.52 8.74916 22.2887 11.7148 20.9902L11.9128 20.8786C11.9676 20.84 12.0117 20.8259 12.0764 20.8259C12.239 20.8259 12.5688 20.8933 13.0271 21.0356C16.0346 22.1129 18.2618 20.3151 19.4114 16.2811C20.8147 11.3556 18.5467 7.8667 15.9963 7.25499C15.5181 7.10206 14.7212 6.94913 14.243 7.10206C14.7283 6.28059 15.4292 5.8504 15.7541 5.7779C16.2509 5.66685 16.5761 5.11299 16.4799 4.54086C16.3836 3.96862 15.9758 3.74559 15.4514 3.80162C14.86 4.05145 14.1231 4.4075 13.5387 5.17798L13.2445 5.63676Z",
|
||||
station: "M18 10V7.17c0-1.62-1.22-3.08-2.84-3.17-1.21-.06-2.27.59-2.8 1.57-.35.65.17 1.43.91 1.43h.01c.34 0 .68-.16.84-.46.16-.32.5-.54.88-.54.55 0 1 .45 1 1v3H8c1.1 0 2-.9 2-2V5c0-.55-.45-1-1-1H5c-.55 0-1 .45-1 1v3c0 1.1.9 2 2 2H3c-.55 0-1 .45-1 1s.45 1 1 1h1v7c0 .55.45 1 1 1h14c.55 0 1-.45 1-1v-7h1c.55 0 1-.45 1-1s-.45-1-1-1zm-5 8h-2v-6h2z",
|
||||
tag: "M17.63 5.84C17.27 5.33 16.67 5 16 5L5 5.01C3.9 5.01 3 5.9 3 7v10c0 1.1.9 1.99 2 1.99L16 19c.67 0 1.27-.33 1.63-.84L22 12z",
|
||||
plus: "M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z",
|
||||
close: "M19 6.41 17.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",
|
||||
chevronDown: "M7.41 8.59 12 13.17l4.59-4.58L18 10l-6 6-6-6z",
|
||||
chevronUp: "M7.41 15.41 12 10.83l4.59 4.58L18 14l-6-6-6 6z",
|
||||
search: "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"
|
||||
};
|
||||
|
||||
export default function WorkspaceFilterBar({
|
||||
type,
|
||||
query = "",
|
||||
recipeCounts,
|
||||
ingredientCounts,
|
||||
itemTypeCounts,
|
||||
tagOptions = [],
|
||||
ingredientOptions = [],
|
||||
currentParams
|
||||
}: Props) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [openChipId, setOpenChipId] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// Mobile detection state
|
||||
const [activeMobileCatId, setActiveMobileCatId] = useState<string | null>(null);
|
||||
const [menuAlignRight, setMenuAlignRight] = useState(false);
|
||||
const [chipAlignRight, setChipAlignRight] = useState(false);
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close menus when clicking outside (desktop)
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (window.innerWidth > 980) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest(".filter-rule-chip-main")) {
|
||||
setOpenChipId(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleToggleMenu = () => {
|
||||
if (!menuOpen && menuRef.current) {
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
setMenuAlignRight(rect.left + 280 > window.innerWidth - 16);
|
||||
}
|
||||
setActiveMobileCatId(null);
|
||||
setMenuOpen(!menuOpen);
|
||||
};
|
||||
|
||||
// Parse active filter parameters
|
||||
const isRecipe = type === "recipe";
|
||||
const isIngredient = type === "ingredient";
|
||||
const isGlobalSearch = !type || Boolean(query);
|
||||
|
||||
const emptyRecipeActive = currentParams["empty_recipe"] === "1";
|
||||
const missingYieldActive = currentParams["missing_yield"] === "1";
|
||||
const placeholderStepsActive = currentParams["placeholder_steps"] === "1";
|
||||
|
||||
const unusedActive = currentParams["unused"] === "1";
|
||||
const missingCostActive = currentParams["missing_cost"] === "1";
|
||||
const noPurchaseActive = currentParams["no_purchase"] === "1" || currentParams["no_usda"] === "1";
|
||||
|
||||
const selectedItemTypes = Array.isArray(currentParams["item_type"])
|
||||
? currentParams["item_type"]
|
||||
: currentParams["item_type"]
|
||||
? [currentParams["item_type"] as string]
|
||||
: [];
|
||||
|
||||
const selectedTags = Array.isArray(currentParams["tag"])
|
||||
? currentParams["tag"]
|
||||
: currentParams["tag"]
|
||||
? [currentParams["tag"] as string]
|
||||
: [];
|
||||
|
||||
const selectedIngredients = Array.isArray(currentParams["ingredient"])
|
||||
? currentParams["ingredient"]
|
||||
: currentParams["ingredient"]
|
||||
? [currentParams["ingredient"] as string]
|
||||
: [];
|
||||
|
||||
// Determine configured categories
|
||||
const categories: FilterCategoryConfig[] = [];
|
||||
|
||||
if (isRecipe) {
|
||||
categories.push({
|
||||
id: "needs_attention",
|
||||
label: "Needs Attention",
|
||||
icon: ICONS.attention,
|
||||
options: [
|
||||
{ id: "empty_recipe", name: "Empty recipes", count: recipeCounts.empty, checked: emptyRecipeActive },
|
||||
{ id: "missing_yield", name: "Missing total yield", count: recipeCounts.missingYield, checked: missingYieldActive },
|
||||
{ id: "placeholder_steps", name: "Contain undefined ingredients / steps", count: recipeCounts.placeholder, checked: placeholderStepsActive }
|
||||
]
|
||||
});
|
||||
categories.push({ id: "tags", label: "Tags", icon: ICONS.tag, options: tagOptions, emptyMessage: "No additional filter options available." });
|
||||
categories.push({ id: "ingredients", label: "Ingredients", icon: ICONS.ingredient, options: ingredientOptions, emptyMessage: "No additional filter options available." });
|
||||
} else if (isIngredient) {
|
||||
categories.push({
|
||||
id: "needs_attention",
|
||||
label: "Needs Attention",
|
||||
icon: ICONS.attention,
|
||||
options: [
|
||||
{ id: "unused", name: "Unused ingredients", count: ingredientCounts.unused, checked: unusedActive },
|
||||
{ id: "missing_cost", name: "Missing cost", count: ingredientCounts.missingCost, checked: missingCostActive },
|
||||
{ id: "no_purchase", name: "No vendor purchase associated", count: ingredientCounts.noPurchase, checked: noPurchaseActive }
|
||||
]
|
||||
});
|
||||
categories.push({ id: "tags", label: "Tags", icon: ICONS.tag, options: tagOptions, emptyMessage: "No additional filter options available." });
|
||||
} else {
|
||||
categories.push({
|
||||
id: "item_type",
|
||||
label: "Item Type",
|
||||
icon: ICONS.tag,
|
||||
options: [
|
||||
{ id: "recipe", name: "Recipes", count: itemTypeCounts?.recipe, checked: selectedItemTypes.includes("recipe") },
|
||||
{ id: "ingredient", name: "Ingredients", count: itemTypeCounts?.ingredient, checked: selectedItemTypes.includes("ingredient") },
|
||||
{ id: "book", name: "Recipe books", count: itemTypeCounts?.book, checked: selectedItemTypes.includes("book") },
|
||||
{ id: "purchase", name: "Purchase items", count: itemTypeCounts?.purchase, checked: selectedItemTypes.includes("purchase") }
|
||||
]
|
||||
});
|
||||
categories.push({ id: "tags", label: "Tags", icon: ICONS.tag, options: tagOptions, emptyMessage: "No additional filter options available." });
|
||||
categories.push({ id: "ingredients", label: "Ingredients", icon: ICONS.ingredient, options: ingredientOptions, emptyMessage: "No additional filter options available." });
|
||||
}
|
||||
|
||||
// Helper to check if a category has active selected options
|
||||
const hasActiveOptions = (cat: FilterCategoryConfig) => {
|
||||
return cat.options.some(opt => opt.checked);
|
||||
};
|
||||
|
||||
const categoriesWithActiveSelections = categories.filter(c => hasActiveOptions(c));
|
||||
const totalActiveCount = categoriesWithActiveSelections.length;
|
||||
|
||||
// The chips visible on screen
|
||||
const visibleCategoryIds = Array.from(
|
||||
new Set([
|
||||
...categoriesWithActiveSelections.map(c => c.id),
|
||||
...(openChipId ? [openChipId] : [])
|
||||
])
|
||||
);
|
||||
|
||||
const navigateWithParams = (newParams: URLSearchParams) => {
|
||||
window.location.href = `${window.location.pathname}?${newParams.toString()}`;
|
||||
};
|
||||
|
||||
const handleAddCategory = (catId: string) => {
|
||||
if (window.innerWidth <= 980) {
|
||||
setActiveMobileCatId(catId);
|
||||
} else {
|
||||
setOpenChipId(catId);
|
||||
setMenuOpen(false);
|
||||
}
|
||||
setSearchTerm("");
|
||||
};
|
||||
|
||||
const handleRemoveCategory = (catId: string) => {
|
||||
if (openChipId === catId) setOpenChipId(null);
|
||||
if (activeMobileCatId === catId) setActiveMobileCatId(null);
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const cat = categories.find(c => c.id === catId);
|
||||
if (cat) {
|
||||
if (catId === "item_type") {
|
||||
params.delete("item_type");
|
||||
} else if (catId === "tags") {
|
||||
params.delete("tag");
|
||||
} else if (catId === "ingredients") {
|
||||
params.delete("ingredient");
|
||||
} else {
|
||||
cat.options.forEach(opt => params.delete(opt.id));
|
||||
params.delete("attention");
|
||||
}
|
||||
navigateWithParams(params);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
setOpenChipId(null);
|
||||
setMenuOpen(false);
|
||||
setActiveMobileCatId(null);
|
||||
const params = new URLSearchParams();
|
||||
if (type) params.set("type", type);
|
||||
if (query) params.set("q", query);
|
||||
navigateWithParams(params);
|
||||
};
|
||||
|
||||
const handleToggleOption = (catId: string, optionId: string, currentChecked: boolean) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (catId === "item_type") {
|
||||
const current = params.getAll("item_type");
|
||||
params.delete("item_type");
|
||||
if (currentChecked) {
|
||||
current.filter(v => v !== optionId).forEach(v => params.append("item_type", v));
|
||||
} else {
|
||||
[...current, optionId].forEach(v => params.append("item_type", v));
|
||||
}
|
||||
} else if (catId === "tags") {
|
||||
const current = params.getAll("tag");
|
||||
params.delete("tag");
|
||||
if (currentChecked) {
|
||||
current.filter(v => v !== optionId).forEach(v => params.append("tag", v));
|
||||
} else {
|
||||
[...current, optionId].forEach(v => params.append("tag", v));
|
||||
}
|
||||
} else if (catId === "ingredients") {
|
||||
const current = params.getAll("ingredient");
|
||||
params.delete("ingredient");
|
||||
if (currentChecked) {
|
||||
current.filter(v => v !== optionId).forEach(v => params.append("ingredient", v));
|
||||
} else {
|
||||
[...current, optionId].forEach(v => params.append("ingredient", v));
|
||||
}
|
||||
} else {
|
||||
if (currentChecked) {
|
||||
params.delete(optionId);
|
||||
} else {
|
||||
params.set(optionId, "1");
|
||||
}
|
||||
}
|
||||
navigateWithParams(params);
|
||||
};
|
||||
|
||||
const handleOpenChip = (catId: string, e?: MouseEvent) => {
|
||||
if (window.innerWidth <= 980) {
|
||||
setActiveMobileCatId(catId);
|
||||
setMenuOpen(true);
|
||||
} else {
|
||||
const isOpening = openChipId !== catId;
|
||||
if (isOpening) {
|
||||
const target = (e?.currentTarget as HTMLElement)?.closest(".filter-rule-item-group");
|
||||
if (target) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
setChipAlignRight(rect.left + 270 > window.innerWidth - 16);
|
||||
}
|
||||
}
|
||||
setOpenChipId(openChipId === catId ? null : catId);
|
||||
}
|
||||
setSearchTerm("");
|
||||
};
|
||||
|
||||
const activeMobileCat = activeMobileCatId ? categories.find(c => c.id === activeMobileCatId) : null;
|
||||
const activeMobileFilteredOptions = activeMobileCat
|
||||
? activeMobileCat.options.filter(opt => opt.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div class="workspace-filter-system">
|
||||
{/* 1. Main Filter Button Trigger in Pills Row */}
|
||||
<div class="workspace-filter-trigger-wrapper" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
class={`workspace-filter-btn ${totalActiveCount > 0 ? "active" : ""}`}
|
||||
onClick={handleToggleMenu}
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true" focusable="false">
|
||||
{ICONS.filter.map((d, i) => (
|
||||
<path key={i} fill="currentColor" d={d} />
|
||||
))}
|
||||
</svg>
|
||||
<span>Filter{totalActiveCount > 0 ? ` (${totalActiveCount})` : ""}</span>
|
||||
</button>
|
||||
|
||||
{/* Desktop Filter Categories Menu */}
|
||||
{menuOpen && (
|
||||
<div class={`workspace-filter-menu-popover desktop-only-filter-popover ${menuAlignRight ? "align-right" : "align-left"}`} role="menu">
|
||||
<div class="filter-menu-header">
|
||||
<h4>Filter</h4>
|
||||
</div>
|
||||
<ul class="filter-menu-list">
|
||||
{categories.map(cat => {
|
||||
const isActive = hasActiveOptions(cat);
|
||||
return (
|
||||
<li key={cat.id}>
|
||||
<button
|
||||
type="button"
|
||||
class={`filter-menu-item ${isActive ? "active" : ""}`}
|
||||
onClick={() => handleAddCategory(cat.id)}
|
||||
>
|
||||
<div class="filter-item-left">
|
||||
<span class="filter-item-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d={cat.icon} />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="filter-item-label">{cat.label}</span>
|
||||
</div>
|
||||
<span class="filter-item-plus" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d={ICONS.plus} />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div class="filter-menu-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="filter-clear-all-btn"
|
||||
disabled={totalActiveCount === 0}
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. Mobile Filter & Sort Modal */}
|
||||
{menuOpen && typeof document !== "undefined" && createPortal(
|
||||
<div class="mobile-filter-backdrop" role="dialog" aria-modal="true" aria-label="Filter and Sort">
|
||||
<div class="mobile-filter-sheet">
|
||||
<header class="mobile-filter-header">
|
||||
{activeMobileCat ? (
|
||||
<button
|
||||
type="button"
|
||||
class="mobile-filter-back-btn"
|
||||
onClick={() => {
|
||||
setActiveMobileCatId(null);
|
||||
setSearchTerm("");
|
||||
}}
|
||||
aria-label="Back to categories"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<div class="mobile-filter-header-spacer" />
|
||||
)}
|
||||
<h3>{activeMobileCat ? activeMobileCat.label : "Filter & Sort"}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="mobile-filter-close-btn"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
setActiveMobileCatId(null);
|
||||
}}
|
||||
aria-label="Close filter"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d={ICONS.close} />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="mobile-filter-body">
|
||||
{activeMobileCat ? (
|
||||
/* Category Options View */
|
||||
<div class="mobile-filter-category-view">
|
||||
{activeMobileCat.options.length > 0 ? (
|
||||
<>
|
||||
<div class="filter-chip-search">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d={ICONS.search} />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder={`Search ${activeMobileCat.label.toLowerCase()}`}
|
||||
value={searchTerm}
|
||||
onInput={e => setSearchTerm((e.target as HTMLInputElement).value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<ul class="filter-chip-options-list mobile-options-list">
|
||||
{activeMobileFilteredOptions.length > 0 ? (
|
||||
activeMobileFilteredOptions.map(opt => (
|
||||
<li key={opt.id}>
|
||||
<label class="filter-chip-option-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={opt.checked}
|
||||
onChange={() => handleToggleOption(activeMobileCat.id, opt.id, opt.checked)}
|
||||
/>
|
||||
<span class="option-name">{opt.name}</span>
|
||||
{typeof opt.count === "number" && (
|
||||
<span class="option-count">({opt.count})</span>
|
||||
)}
|
||||
</label>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li class="filter-chip-empty">No matching options.</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<div class="filter-chip-empty-message">
|
||||
{activeMobileCat.emptyMessage || "No additional filter options available."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Main Filter & Sort Menu */
|
||||
<div class="mobile-filter-main-view">
|
||||
<div class="mobile-filter-section-heading">
|
||||
<svg viewBox="0 0 18 21" aria-hidden="true" class="mobile-section-icon">
|
||||
{ICONS.filter.map((d, i) => (
|
||||
<path key={i} fill="currentColor" d={d} />
|
||||
))}
|
||||
</svg>
|
||||
<span>Filter</span>
|
||||
</div>
|
||||
|
||||
<div class="mobile-filter-card">
|
||||
{categories.map(cat => {
|
||||
const isActive = hasActiveOptions(cat);
|
||||
const activeOptsCount = cat.options.filter(o => o.checked).length;
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
class={`mobile-filter-card-item ${isActive ? "active" : ""}`}
|
||||
onClick={() => handleAddCategory(cat.id)}
|
||||
>
|
||||
<div class="filter-item-left">
|
||||
<span class="filter-item-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d={cat.icon} />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="filter-item-label">{cat.label}</span>
|
||||
{activeOptsCount > 0 && (
|
||||
<span class="mobile-active-badge">({activeOptsCount})</span>
|
||||
)}
|
||||
</div>
|
||||
<span class="mobile-item-chevron" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M8.59 16.59 13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="mobile-clear-card">
|
||||
<button
|
||||
type="button"
|
||||
class="mobile-clear-btn"
|
||||
disabled={totalActiveCount === 0}
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* 3. Secondary Active Filter Rule Chips Bar (Horizontal Scroll on Mobile) */}
|
||||
{visibleCategoryIds.length > 0 && (
|
||||
<div class="filter-rules-row" role="region" aria-label="Active filters">
|
||||
{visibleCategoryIds.map((catId, index) => {
|
||||
const cat = categories.find(c => c.id === catId);
|
||||
if (!cat) return null;
|
||||
const isOpen = openChipId === catId;
|
||||
const filteredOptions = cat.options.filter(opt =>
|
||||
opt.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={catId} class="filter-rule-item-group">
|
||||
{index > 0 && <span class="filter-connector-and">And</span>}
|
||||
|
||||
<div class={`filter-rule-chip ${isOpen ? "open" : ""}`} data-cat-id={catId}>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-rule-chip-main"
|
||||
onClick={(e) => handleOpenChip(catId, e as any)}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span class="filter-chip-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d={cat.icon} />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="filter-chip-label">{cat.label}:</span>
|
||||
<span class="filter-chip-arrow">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d={isOpen ? ICONS.chevronUp : ICONS.chevronDown} />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="filter-rule-chip-close"
|
||||
onClick={() => handleRemoveCategory(catId)}
|
||||
aria-label={`Remove ${cat.label} filter`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d={ICONS.close} />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown Popover for Active Chip (Desktop) */}
|
||||
{isOpen && (
|
||||
<div class={`filter-chip-popover desktop-only-filter-popover ${chipAlignRight ? "align-right" : "align-left"}`} ref={popoverRef}>
|
||||
{cat.options.length > 0 ? (
|
||||
<>
|
||||
<div class="filter-chip-search">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d={ICONS.search} />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder={`Search ${cat.label.toLowerCase()}`}
|
||||
value={searchTerm}
|
||||
onInput={e => setSearchTerm((e.target as HTMLInputElement).value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<ul class="filter-chip-options-list">
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map(opt => (
|
||||
<li key={opt.id}>
|
||||
<label class="filter-chip-option-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={opt.checked}
|
||||
onChange={() => handleToggleOption(catId, opt.id, opt.checked)}
|
||||
/>
|
||||
<span class="option-name">{opt.name}</span>
|
||||
{typeof opt.count === "number" && (
|
||||
<span class="option-count">({opt.count})</span>
|
||||
)}
|
||||
</label>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li class="filter-chip-empty">No matching options.</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<div class="filter-chip-empty-message">
|
||||
{cat.emptyMessage || "No additional filter options available."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,356 @@
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
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();
|
||||
|
||||
/** Commit a number input's value, returning the parsed number or undefined. */
|
||||
const commitNumericInput = (input: HTMLInputElement): number | undefined => {
|
||||
const num = Number(input.value);
|
||||
return Number.isFinite(num) ? num : undefined;
|
||||
};
|
||||
|
||||
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) {
|
||||
// Uncontrolled refs for numeric inputs — avoids Preact fighting
|
||||
// intermediate values like "2." during decimal entry.
|
||||
const qtyInputRef = useRef<HTMLInputElement>(null);
|
||||
const qtyTracker = useRef(item.quantity);
|
||||
useEffect(() => {
|
||||
// Sync DOM value when parent changes externally (e.g. percent-driven)
|
||||
if (item.quantity !== qtyTracker.current && qtyInputRef.current) {
|
||||
qtyTracker.current = item.quantity;
|
||||
if (document.activeElement !== qtyInputRef.current) {
|
||||
qtyInputRef.current.value = String(item.quantity);
|
||||
}
|
||||
}
|
||||
}, [item.quantity]);
|
||||
|
||||
const pctInputRef = useRef<HTMLInputElement>(null);
|
||||
const percentDisplay = percentValue != null ? Math.round(percentValue * 1000) / 1000 : undefined;
|
||||
const pctTracker = useRef(percentDisplay);
|
||||
useEffect(() => {
|
||||
if (percentDisplay !== pctTracker.current && pctInputRef.current) {
|
||||
pctTracker.current = percentDisplay;
|
||||
if (document.activeElement !== pctInputRef.current) {
|
||||
pctInputRef.current.value = percentDisplay != null ? String(percentDisplay) : "";
|
||||
}
|
||||
}
|
||||
}, [percentDisplay]);
|
||||
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
|
||||
ref={qtyInputRef}
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
aria-label="Quantity"
|
||||
defaultValue={item.quantity}
|
||||
onBlur={(event) => {
|
||||
const num = commitNumericInput(event.currentTarget);
|
||||
if (num != null) {
|
||||
qtyTracker.current = num;
|
||||
onUpdateQuantity(num);
|
||||
} else {
|
||||
event.currentTarget.value = String(item.quantity);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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
|
||||
ref={pctInputRef}
|
||||
aria-label={`${label} percentage`}
|
||||
type="number"
|
||||
min="0"
|
||||
max={
|
||||
percentMode === "standard" || item.basis_member
|
||||
? 99.999
|
||||
: undefined
|
||||
}
|
||||
step="any"
|
||||
defaultValue={percentDisplay}
|
||||
onBlur={(event) => {
|
||||
const num = commitNumericInput(event.currentTarget);
|
||||
if (num != null) {
|
||||
pctTracker.current = num;
|
||||
onSetPercent(num);
|
||||
} else {
|
||||
event.currentTarget.value = percentDisplay != null ? String(percentDisplay) : "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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,72 @@
|
||||
/**
|
||||
* Formulation - Centralized Icon Library
|
||||
* SVG icons and path definitions for workspace design system
|
||||
*/
|
||||
|
||||
export type TabIconKey = "method" | "costing" | "equivalencies" | "nutrition" | "formula" | "overview";
|
||||
export type EntityIconKey = "recipe" | "ingredient" | "book" | "purchase" | "inventory";
|
||||
export type UiIconKey = "chevronRight" | "chevronLeft" | "edit" | "more" | "plus" | "search" | "info" | "warning";
|
||||
|
||||
/**
|
||||
* Tab Icons (viewBox & SVG path) used in workspace & entity read subheaders
|
||||
*/
|
||||
export const TAB_ICONS: Record<TabIconKey, string> = {
|
||||
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: "M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35zM7.5 5C5.57 5 4 6.57 4 8.5c0 2.24 1.95 4.67 4.9 7.37L12 18.72l3.1-2.85C18.05 13.17 20 10.74 20 8.5c0-1.93-1.57-3.5-3.5-3.5-1.54 0-2.94.99-3.57 2.36h-1.87C10.44 5.99 9.04 5 7.5 5zm-3 6.5h3.9l1.6-3.2 2.2 5.3 2-4.6.8.8H19.5v1.4h-3.3l-1.2-1.2-1.8 4.2-2.3-5.5-1.7 3.4H4.5v-1.4z",
|
||||
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",
|
||||
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"
|
||||
};
|
||||
|
||||
export const TAB_VIEWBOXES: Record<TabIconKey, string> = {
|
||||
method: "0 0 24 24",
|
||||
costing: "0 0 24 24",
|
||||
equivalencies: "0 0 24 24",
|
||||
nutrition: "0 0 24 24",
|
||||
formula: "0 0 14 16",
|
||||
overview: "0 0 24 24"
|
||||
};
|
||||
|
||||
/**
|
||||
* Entity Type Circular Badge Icons
|
||||
*/
|
||||
export const TYPE_ICONS: Record<EntityIconKey, 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"
|
||||
};
|
||||
|
||||
export const TYPE_ICON_TRANSFORMS: Record<EntityIconKey, 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
|
||||
};
|
||||
|
||||
/**
|
||||
* General UI & Action Icons
|
||||
*/
|
||||
export const UI_ICONS: Record<UiIconKey, string> = {
|
||||
chevronRight: "M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z",
|
||||
chevronLeft: "M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z",
|
||||
edit: "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",
|
||||
more: "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",
|
||||
plus: "M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z",
|
||||
search: "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",
|
||||
info: "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",
|
||||
warning: "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 2M12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8"
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate tab icon markup for Astro / HTML templates
|
||||
*/
|
||||
export function getTabIconHtml(key: TabIconKey): string {
|
||||
const viewBox = TAB_VIEWBOXES[key] || "0 0 24 24";
|
||||
const path = TAB_ICONS[key];
|
||||
if (!path) return "";
|
||||
return `<span class="recipe-tab-icon"><svg viewBox="${viewBox}" aria-hidden="true"><path fill="currentColor" d="${path}"/></svg></span>`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user