Compare commits

...
2 Commits
64 changed files with 7186 additions and 569 deletions
+20 -13
View File
@@ -38,11 +38,10 @@ 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.
All normal recipe, ingredient, nutrition-mapping, and purchasing changes must
be written to SQLite through the application or its validated database
@@ -51,26 +50,34 @@ 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.
Create a new local database from the portable culinary dataset:
### 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]
```
**Warning:** this command deletes and replaces the existing local database with
the contents of `culinary/`. Any newer SQLite-only edits will be lost. There is
intentionally no legacy upgrade chain. YAML under `culinary/` is retained as
portable seed and interchange data; it is not a second writable source of
truth.
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, including the distinction between a
seed rebuild and transferring current SQLite data, see
[Agent handoff](docs/agent-handoff.md).
For moving development to another machine, see [Agent handoff](docs/agent-handoff.md).
## Development
+15 -12
View File
@@ -10,28 +10,31 @@ 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.
## Start from the committed seed data
## 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 db:reset
npm run dev:app
```
`db:reset` deletes the local database before importing `culinary/`. Do not run it
when a newer SQLite database has been transferred from another installation.
### Backups and Transfers
## Transfer the latest application data
The runtime database lives under `var/`, which is intentionally ignored by Git.
The runtime database and its backups live under `var/`, which is intentionally
ignored by Git. A clone therefore contains the application and portable seed,
but not necessarily the latest recipe edits.
To hand off the current live state, create a consistent SQLite backup separately
from Git:
To backup or hand off the current live state:
```sh
npm run db:backup -- /safe/transfer/recipe-book.sqlite
# 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.
+320
View File
@@ -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
```
@@ -1,12 +1,12 @@
# Count Sheets & Storage Locations
Inventory in Formulation is built for high-speed, sheet-to-shelf counting across physical storage locations.
Inventory in Formulation is designed for fast, sheet-to-shelf counting across physical kitchen storage locations.
---
## 1. Storage Locations
Organize your kitchen into logical physical zones:
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.
@@ -17,8 +17,10 @@ Organize your kitchen into logical physical zones:
## 2. Conducting an Inventory Count
1. **Start a Count Session**: Open `/app/inventory/` and click **+ New Count Session**.
2. **Sheet-to-Shelf Counting**: Open the count sheet and filter by physical location. Items appear in the exact physical order of your storage shelves.
3. **Enter On-Hand Quantities**: Type counted units (e.g. `4.5` bags, `12` each, `25` lbs).
4. **Live Valuation**: Formulation instantly computes the **Extended Value ($)** for each item based on current vendor purchase costs.
5. **Complete & Finalize**: Submit the count to freeze period-end inventory valuation.
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.
+11 -9
View File
@@ -1,6 +1,6 @@
# Baker's & Standard Percentages
In baking and commercial food manufacturing, formulas are often structured using **percentages** to ensure recipe scalability and hydration control.
In baking and commercial food manufacturing, formulas use **percentages** to ensure recipe scalability and hydration control.
---
@@ -9,18 +9,20 @@ In baking and commercial food manufacturing, formulas are often structured using
### 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%**.
- Useful for confectionery, dressings, beverages, and general culinary batching.
- 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(s) or designated base ingredients are flagged as **Base Members** (`basis_member = true`) and sum to **100%**.
- All other ingredients (water/hydration, salt, yeast, sugar, butter) are expressed as a percentage relative to the total flour weight (e.g. 75% hydration water, 2% salt, 1.5% yeast).
- 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. Dynamic Interactive Percentage Editing
## 2. Using Interactive Percentage Editing
When editing in the Recipe Structure Editor:
1. Enable **Calculate %** and select **Baker's %** or **Standard %**.
2. For Baker's %, toggle the **Base** checkbox on the flour/grain ingredients.
3. Editing an ingredient's percentage dynamically recalibrates its required physical weight and quantity in grams automatically!
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.
+20 -13
View File
@@ -1,6 +1,6 @@
# Scaling, Batching & Yield Calculations
Formulation is a weight-first formulation engine designed to scale recipes accurately across commercial batch sizes without calculation rounding drift.
Formulation is a weight-first formulation engine designed to scale recipes across commercial batch sizes without calculation rounding drift.
---
@@ -8,27 +8,34 @@ Formulation is a weight-first formulation engine designed to scale recipes accur
Recipes can be scaled in two primary modes:
### A. Batch Multiplier (`x` factor)
- Entering a batch multiplier (e.g. `0.5x`, `2x`, `5x`, `10x`) multiplies every ingredient quantity by the exact factor.
- Pre-configured quick buttons allow instant one-tap scaling during active kitchen prep.
### 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
- Specify an exact required total batch yield (e.g., scale a sauce recipe to yield exactly `1,500 grams` or `4.5 quarts`).
- Formulation calculates the precise scale factor required to produce that yield based on total recipe weight.
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
When **Auto-Calculate Total Yield** is toggled ON:
1. Formulation calculates the weight in grams for every ingredient in the recipe using canonical unit conversion factors or ingredient-specific density measurements.
2. The total recipe yield quantity is automatically updated as the exact sum of all ingredient weights.
3. If an ingredient cannot be converted to weight (due to a missing UoM volume-to-weight equivalency), an informative notice displays: *"Auto yield excludes N ingredient amounts without a weight equivalency."*
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 (e.g. `g`, `kg`, `oz`, `lb`) convert losslessly across all mass units.
- Ingredients measured in volume (e.g. `cup`, `tbsp`, `tsp`, `liter`, `ml`) require an ingredient density measurement (e.g. `1 cup = 120g`) to convert to mass.
- Cross-dimensional universal conversions without density data are intentionally rejected to maintain strict culinary accuracy.
- 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.
+32 -10
View File
@@ -1,23 +1,45 @@
# Sub-recipes & Prep Methods
Recipes in Formulation can seamlessly nest other recipes as **sub-recipes**, enabling modular batch preparation and accurate cost/nutrition rollup.
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
- **Nesting**: When adding an ingredient to a recipe row, you can select an existing Recipe (tagged with the blue Recipe badge) rather than a raw ingredient.
- **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 (e.g. *House Mayonnaise*) automatically propagate up to all dishes that include it (e.g. *Aioli*, *Tartar Sauce*, *Sandwich Spread*).
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 allows organizing kitchen instructions into numbered, ordered steps:
The **Prep Method** editor organizes kitchen instructions into ordered, sequential steps.
- **Numbered Instructions**: Step-by-step prep directions with drag-and-drop reordering handles.
- **Section Headers**: Add intermediate headings (e.g. `To Sear:`, `Dry Mix:`, `To Garnish:`) by ending the line with a colon `:`.
- **Prep Notes**: Add inline notes (e.g. `(Let rest for 15 minutes before carving)`) by wrapping text in parentheses `(...)`.
- **Bulk Prep Import**: Paste entire text documents or recipes into the bulk import modal to automatically parse headings, numbered steps, and notes into structured cards.
### 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.
+16 -12
View File
@@ -25,11 +25,9 @@ agent request
-> optional explicit export for backup or review
```
Create the initial database from the current portable dataset with Node 22 or
newer, then run either application mode:
Run either application mode with Node 22 or newer:
```sh
npm run db:reset
npm run dev:readonly
npm run dev:app
```
@@ -39,16 +37,22 @@ 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 delete and replace the
database from portable YAML. They are intended only for initial setup or an
explicit restore. Running either command after application edits can discard
newer SQLite-only data.
### 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
+114
View File
@@ -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.
+902 -387
View File
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -19,16 +19,19 @@
"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:backup": "node scripts/db-backup.mjs",
"db:reset": "node scripts/db-sync.mjs --reset",
"db:import:yaml": "node scripts/db-sync.mjs --reset"
"db:backup": "node scripts/backup.mjs export",
"db:restore": "node scripts/backup.mjs import",
"db:validate": "node scripts/backup.mjs validate",
"mcp": "node scripts/mcp-server.mjs"
},
"dependencies": {
"@astrojs/node": "^11.1.1",
"@astrojs/preact": "6.0.2",
"@modelcontextprotocol/sdk": "^1.30.0",
"astro": "7.2.1",
"preact": "10.29.8",
"yaml": "2.9.0"
"yaml": "2.9.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@astrojs/check": "0.9.4",
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { DatabaseSync } from "node:sqlite";
const root = path.resolve(import.meta.dirname, "..");
const databasePath = path.join(root, "var", "recipe-book.sqlite");
// Dynamically import compiled or source backup engine
import { exportDatabase } from "../src/lib/backup/export-database.ts";
import { importDatabase } from "../src/lib/backup/import-database.ts";
import { validateBackupBundle } from "../src/lib/backup/validate-backup.ts";
function printUsage() {
console.log(`
Formulation Database Backup & Restore Tool
Usage:
node scripts/backup.mjs export [output-path.json]
node scripts/backup.mjs import <input-path.json> [--replace | --merge]
node scripts/backup.mjs validate <input-path.json>
Commands:
export Extracts all 25 SQLite tables into a standardized JSON backup bundle.
import Restores or merges a backup bundle into the active SQLite database.
validate Checks a backup JSON file for schema integrity without modifying the database.
Options:
--replace (Default for import) Atomically replaces existing database records.
--merge Upserts imported records without deleting unmentioned existing data.
`);
}
async function main() {
const args = process.argv.slice(2);
const command = args[0]?.toLowerCase();
if (!command || command === "--help" || command === "-h" || command === "help") {
printUsage();
process.exit(0);
}
if (command === "export") {
if (!fs.existsSync(databasePath)) {
console.error(`Error: Database not found at ${databasePath}`);
process.exit(1);
}
const db = new DatabaseSync(databasePath);
try {
console.log("Exporting full Formulation database...");
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.writeFileSync(outputPath, JSON.stringify(bundle, null, 2), "utf8");
console.log(`\n✅ Backup exported successfully to: ${outputPath}`);
console.log(` - Recipes: ${bundle.summary.recipes_count}`);
console.log(` - Ingredients: ${bundle.summary.ingredients_count}`);
console.log(` - Purchase Items: ${bundle.summary.purchase_items_count}`);
console.log(` - Collections: ${bundle.summary.collections_count}`);
console.log(` - Inventory Counts: ${bundle.summary.inventory_counts_count}`);
console.log(` - Total Entities: ${bundle.summary.total_records_count}`);
} finally {
db.close();
}
return;
}
if (command === "validate") {
const inputPath = args[1];
if (!inputPath) {
console.error("Error: Please specify the path to a backup JSON file to validate.");
process.exit(1);
}
const resolvedPath = path.resolve(process.cwd(), inputPath);
if (!fs.existsSync(resolvedPath)) {
console.error(`Error: File not found: ${resolvedPath}`);
process.exit(1);
}
const content = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
const result = validateBackupBundle(content);
if (result.valid) {
console.log(`\n✅ Backup file '${inputPath}' is valid!`);
if (result.summary) {
console.log(` - Format Version: ${content.format_version}`);
console.log(` - Exported At: ${content.exported_at}`);
console.log(` - Recipes: ${result.summary.recipes_count}`);
console.log(` - Ingredients: ${result.summary.ingredients_count}`);
console.log(` - Purchase Items: ${result.summary.purchase_items_count}`);
console.log(` - Inventory Counts: ${result.summary.inventory_counts_count}`);
console.log(` - Total Entities: ${result.summary.total_records_count}`);
}
} else {
console.error(`\n❌ Backup validation failed:`);
for (const err of result.errors) console.error(` - ${err}`);
process.exit(1);
}
return;
}
if (command === "import") {
const inputPath = args[1];
if (!inputPath) {
console.error("Error: Please specify the path to a backup JSON file to import.");
process.exit(1);
}
const resolvedPath = path.resolve(process.cwd(), inputPath);
if (!fs.existsSync(resolvedPath)) {
console.error(`Error: File not found: ${resolvedPath}`);
process.exit(1);
}
const mode = args.includes("--merge") ? "merge" : "replace";
const content = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
if (!fs.existsSync(databasePath)) {
console.error(`Error: Database not found at ${databasePath}`);
process.exit(1);
}
const db = new DatabaseSync(databasePath);
try {
console.log(`Importing '${inputPath}' into database (mode: ${mode})...`);
const result = importDatabase(db, content, { mode, rebuildProjections: true });
console.log(`\n${result.message}`);
} catch (err) {
console.error(`\n❌ Import failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
} finally {
db.close();
}
return;
}
console.error(`Error: Unknown command '${command}'`);
printUsage();
process.exit(1);
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});
+15 -4
View File
@@ -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]);
+1 -3
View File
@@ -48,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;
}
+146
View File
@@ -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();
+335
View File
@@ -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,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();
}
};
+36
View File
@@ -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();
}
};
+4 -1
View File
@@ -109,7 +109,10 @@ const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/in
{tabs.map((tab)=><a class:list={{active:type===tab.type}} href={tab.href ?? `/app/?type=${tab.type}`}><span class={`workspace-pill-icon ${tab.kind}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d={TYPE_ICONS[tab.kind]} style={TYPE_ICON_TRANSFORMS[tab.kind]?{transform:TYPE_ICON_TRANSFORMS[tab.kind]}:undefined}/></svg></span><span>{tab.label}</span><small>{tab.count}</small></a>)}
{type==="ingredient"&&<details class="filter-menu" open={filtering}><summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Filter{filtering?` · ${filteredIngredients.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="ingredient"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="missing_cost" value="1" checked={missingCost}/> Missing cost</label><label><input type="checkbox" name="no_usda" value="1" checked={noUsda}/> No USDA map</label><label><input type="checkbox" name="unused" value="1" checked={unused}/> Unused ingredient</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=ingredient">Clear</a>}</div></form></details>}
{type==="recipe"&&<details class="filter-menu" open={filtering}><summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Filter{filtering?` · ${filteredRecipes.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="recipe"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="empty_recipe" value="1" checked={emptyRecipe}/> Empty recipe</label><label><input type="checkbox" name="placeholder_steps" value="1" checked={placeholderSteps}/> Placeholder instructions</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=recipe">Clear</a>}</div></form></details>}
{!readOnlyMode&&<a class="archive-link" href="/app/archive/">Archive</a>}
{!readOnlyMode&&<>
<a class="archive-link" href="/app/archive/">Archive</a>
<a class="archive-link" href="/app/settings/">Data Management</a>
</>}
</nav>
{query?<section class="workspace-search-results" aria-live="polite"><p><strong>{searchResults.length}</strong> {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}</p>{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}/>}
@@ -540,7 +540,7 @@ const isCompleted = count.status === "completed";
text-align: left;
}
.count-items-table th {
background: #fbfbfb;
background: #ffffff;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
@@ -317,7 +317,7 @@ database.close();
gap: 16px;
}
.inventory-table-head {
background: #fbfbfb;
background: #ffffff;
border-bottom: 1px solid var(--line);
font-size: 11px;
text-transform: uppercase;
@@ -465,7 +465,7 @@ database.close();
display: flex;
justify-content: flex-end;
gap: 10px;
background: #fbfbfb;
background: #ffffff;
}
.cancel-btn {
height: 36px;
+27 -3
View File
@@ -278,9 +278,33 @@ const recipeTabIcon=(name:string)=>`<span class="recipe-tab-icon"><svg viewBox="
</section>
) : (
<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}</span></li>)}
<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>
)}
@@ -0,0 +1,684 @@
---
export const prerender = false;
import BaseLayout from "../../../../layouts/BaseLayout.astro";
import DetailUtility from "../../../../components/DetailUtility.astro";
import { openDatabase } from "../../../../lib/database";
import { exportDatabase } from "../../../../lib/backup";
import { readOnlyMode } from "../../../../lib/runtime";
const database = openDatabase({ readOnly: true });
if (!database) return Astro.redirect("/app/", 303);
let summary = {
recipes_count: 0,
ingredients_count: 0,
purchase_items_count: 0,
collections_count: 0,
inventory_counts_count: 0,
inventory_locations_count: 0,
total_records_count: 0,
};
try {
const bundle = exportDatabase(database);
summary = bundle.summary;
} catch (err) {
console.error("Failed to compute database summary:", err);
} finally {
database.close();
}
---
<BaseLayout title="Data Management & Backups" immersive>
<div class="settings-page">
<DetailUtility section="Data Management" sectionHref="/app/settings/" />
<main class="settings-workspace">
<header class="settings-header">
<div class="settings-header-left">
<h1>Data Management</h1>
<p class="settings-subtitle">
Export complete database archives or restore backups across all 25 tables with full referential integrity.
</p>
</div>
</header>
<div class="settings-grid">
<!-- Database Overview Card -->
<section class="settings-card db-status-card">
<div class="card-header">
<div class="card-icon status-icon">
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
<path d="M12 2C6.48 2 2 4.02 2 6.5v11C2 19.98 6.48 22 12 22s10-2.02 10-4.5v-11C22 4.02 17.52 2 12 2zm0 2c4.42 0 8 1.45 8 2.5S16.42 9 12 9 4 7.55 4 6.5 7.58 4 12 4zm8 13.5c0 1.05-3.58 2.5-8 2.5s-8-1.45-8-2.5V14.2c1.94 1.14 4.8 1.8 8 1.8s6.06-.66 8-1.8v3.3zm0-5.5c0 1.05-3.58 2.5-8 2.5s-8-1.45-8-2.5V8.7c1.94 1.14 4.8 1.8 8 1.8s6.06-.66 8-1.8V12z"/>
</svg>
</div>
<div>
<h2>Active Database Status</h2>
<span class="card-caption">SQLite local storage ({summary.total_records_count} total entities)</span>
</div>
</div>
<div class="stats-metrics-grid">
<div class="stat-pill">
<span class="stat-label">Recipes</span>
<span class="stat-value">{summary.recipes_count}</span>
</div>
<div class="stat-pill">
<span class="stat-label">Ingredients</span>
<span class="stat-value">{summary.ingredients_count}</span>
</div>
<div class="stat-pill">
<span class="stat-label">Purchase Items</span>
<span class="stat-value">{summary.purchase_items_count}</span>
</div>
<div class="stat-pill">
<span class="stat-label">Recipe Books</span>
<span class="stat-value">{summary.collections_count}</span>
</div>
<div class="stat-pill">
<span class="stat-label">Inventory Counts</span>
<span class="stat-value">{summary.inventory_counts_count}</span>
</div>
<div class="stat-pill">
<span class="stat-label">Storage Locations</span>
<span class="stat-value">{summary.inventory_locations_count}</span>
</div>
</div>
</section>
<!-- Export Backup Card -->
<section class="settings-card export-card">
<div class="card-header">
<div class="card-icon export-icon">
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z"/>
</svg>
</div>
<div>
<h2>Export Database Backup</h2>
<span class="card-caption">Download full-fidelity JSON archive</span>
</div>
</div>
<p class="card-description">
Generates a portable backup bundle containing 100% of your formulations, ingredients, purchasing catalogs, cost histories, and inventory count sessions.
</p>
<div class="card-actions">
<a href="/api/app/backup/export" download class="btn-primary export-btn" id="exportBackupBtn">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
<span>Export Full Backup (.json)</span>
</a>
</div>
</section>
<!-- Import & Restore Card -->
<section class="settings-card import-card">
<div class="card-header">
<div class="card-icon import-icon">
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/>
</svg>
</div>
<div>
<h2>Import & Restore Backup</h2>
<span class="card-caption">Restore database from backup JSON</span>
</div>
</div>
{readOnlyMode ? (
<div class="notice readonly-notice">
Database restoration is disabled in read-only demonstration mode.
</div>
) : (
<div class="import-interactive-zone">
<div class="drop-zone" id="backupDropZone">
<input type="file" id="backupFileInput" accept=".json" class="file-input-hidden" />
<svg viewBox="0 0 24 24" width="36" height="36" fill="currentColor" class="drop-icon">
<path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z"/>
</svg>
<p class="drop-title">Select or drag a Formulation backup (.json) file</p>
<button type="button" class="btn-secondary" id="browseFileBtn">Browse File</button>
</div>
<!-- Preview Dialog (Hidden until file selected) -->
<div class="import-preview-box" id="importPreviewBox" style="display: none;">
<div class="preview-header">
<h3>Backup File Validation</h3>
<span class="badge-valid" id="validationBadge">Valid</span>
</div>
<div class="preview-metrics" id="previewMetrics">
<!-- Injected via JavaScript -->
</div>
<div class="mode-selection-group">
<label class="mode-radio">
<input type="radio" name="importMode" value="replace" checked />
<div class="mode-text">
<strong>Clean Restore (Replace)</strong>
<span>Safely replaces all database records with this backup snapshot inside an atomic transaction.</span>
</div>
</label>
<label class="mode-radio">
<input type="radio" name="importMode" value="merge" />
<div class="mode-text">
<strong>Smart Merge (Upsert)</strong>
<span>Updates existing items and adds new items without deleting existing data.</span>
</div>
</label>
</div>
<div class="preview-actions">
<button type="button" class="btn-primary" id="confirmImportBtn">
Restore Database Now
</button>
<button type="button" class="btn-secondary" id="cancelImportBtn">
Cancel
</button>
</div>
</div>
<!-- Status Banner -->
<div class="import-status-banner" id="importStatusBanner" style="display: none;"></div>
</div>
)}
</section>
</div>
</main>
</div>
</BaseLayout>
<script>
const fileInput = document.getElementById("backupFileInput") as HTMLInputElement | null;
const browseBtn = document.getElementById("browseFileBtn") as HTMLButtonElement | null;
const dropZone = document.getElementById("backupDropZone") as HTMLDivElement | null;
const previewBox = document.getElementById("importPreviewBox") as HTMLDivElement | null;
const previewMetrics = document.getElementById("previewMetrics") as HTMLDivElement | null;
const validationBadge = document.getElementById("validationBadge") as HTMLSpanElement | null;
const confirmBtn = document.getElementById("confirmImportBtn") as HTMLButtonElement | null;
const cancelBtn = document.getElementById("cancelImportBtn") as HTMLButtonElement | null;
const statusBanner = document.getElementById("importStatusBanner") as HTMLDivElement | null;
let loadedBundle: any = null;
if (browseBtn && fileInput) {
browseBtn.addEventListener("click", () => fileInput.click());
}
if (dropZone && fileInput) {
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.classList.add("dragover");
});
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("dragover"));
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("dragover");
if (e.dataTransfer?.files.length) {
handleFile(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener("change", () => {
if (fileInput.files?.length) {
handleFile(fileInput.files[0]);
}
});
}
if (cancelBtn) {
cancelBtn.addEventListener("click", resetImport);
}
async function handleFile(file: File) {
if (!file.name.endsWith(".json")) {
showStatus("Please select a valid .json file.", "error");
return;
}
try {
showStatus("Validating backup file...", "info");
const text = await file.text();
const parsed = JSON.parse(text);
const res = await fetch("/api/app/backup/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(parsed),
});
const validation = await res.json();
if (!validation.valid) {
showStatus(`Invalid backup file: ${validation.errors.join("; ")}`, "error");
return;
}
loadedBundle = parsed;
hideStatus();
if (previewBox && previewMetrics && validationBadge) {
dropZone!.style.display = "none";
previewBox.style.display = "block";
const s = validation.summary;
previewMetrics.innerHTML = `
<div class="metric-row"><strong>Exported At:</strong> <span>${new Date(parsed.exported_at).toLocaleString()}</span></div>
<div class="metric-row"><strong>Format Version:</strong> <span>${parsed.format_version}</span></div>
<div class="metric-row"><strong>Recipes:</strong> <span>${s.recipes_count}</span></div>
<div class="metric-row"><strong>Ingredients:</strong> <span>${s.ingredients_count}</span></div>
<div class="metric-row"><strong>Purchase Items:</strong> <span>${s.purchase_items_count}</span></div>
<div class="metric-row"><strong>Inventory Counts:</strong> <span>${s.inventory_counts_count}</span></div>
<div class="metric-row"><strong>Total Records:</strong> <span>${s.total_records_count}</span></div>
`;
}
} catch (err) {
showStatus(`Failed to read file: ${err instanceof Error ? err.message : String(err)}`, "error");
}
}
if (confirmBtn) {
confirmBtn.addEventListener("click", async () => {
if (!loadedBundle) return;
const modeRadio = document.querySelector('input[name="importMode"]:checked') as HTMLInputElement | null;
const mode = modeRadio?.value ?? "replace";
confirmBtn.disabled = true;
confirmBtn.textContent = "Restoring Database...";
try {
const res = await fetch(`/api/app/backup/import?mode=${mode}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(loadedBundle),
});
const result = await res.json();
if (!res.ok || !result.success) {
showStatus(`Import failed: ${result.error || "Unknown error"}`, "error");
confirmBtn.disabled = false;
confirmBtn.textContent = "Restore Database Now";
return;
}
showStatus(result.message + " Reloading workspace...", "success");
setTimeout(() => {
window.location.reload();
}, 1500);
} catch (err) {
showStatus(`Import error: ${err instanceof Error ? err.message : String(err)}`, "error");
confirmBtn.disabled = false;
confirmBtn.textContent = "Restore Database Now";
}
});
}
function resetImport() {
loadedBundle = null;
if (fileInput) fileInput.value = "";
if (previewBox) previewBox.style.display = "none";
if (dropZone) dropZone.style.display = "flex";
hideStatus();
}
function showStatus(message: string, type: "info" | "error" | "success") {
if (!statusBanner) return;
statusBanner.className = `import-status-banner status-${type}`;
statusBanner.textContent = message;
statusBanner.style.display = "block";
}
function hideStatus() {
if (statusBanner) statusBanner.style.display = "none";
}
</script>
<style>
.settings-page {
background: #f8fafc;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.settings-workspace {
max-width: 1000px;
width: 100%;
margin: 0 auto;
padding: 32px 24px 64px;
box-sizing: border-box;
}
.settings-header {
margin-bottom: 28px;
}
.settings-header h1 {
font-size: 24px;
font-weight: 700;
color: #050841;
margin: 0 0 6px;
letter-spacing: -0.01em;
}
.settings-subtitle {
font-size: 14px;
color: #64748b;
margin: 0;
}
.settings-grid {
display: flex;
flex-direction: column;
gap: 24px;
}
.settings-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.card-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 16px;
}
.card-icon {
width: 44px;
height: 44px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.status-icon {
background: #eff6ff;
color: #2563eb;
}
.export-icon {
background: #f0fdf4;
color: #16a34a;
}
.import-icon {
background: #fef2f2;
color: #dc2626;
}
.card-header h2 {
font-size: 17px;
font-weight: 600;
color: #050841;
margin: 0 0 2px;
}
.card-caption {
font-size: 13px;
color: #64748b;
}
.card-description {
font-size: 14px;
color: #475569;
line-height: 1.5;
margin: 0 0 20px;
}
.stats-metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
margin-top: 12px;
}
.stat-pill {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 12px;
font-weight: 500;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: #050841;
}
.card-actions {
display: flex;
align-items: center;
gap: 12px;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 8px;
background: #2563eb;
color: #ffffff;
font-size: 14px;
font-weight: 600;
padding: 10px 18px;
border-radius: 6px;
text-decoration: none;
border: none;
cursor: pointer;
transition: background 0.15s ease;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-primary:disabled {
background: #94a3b8;
cursor: not-allowed;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 6px;
background: #ffffff;
color: #334155;
font-size: 14px;
font-weight: 600;
padding: 9px 16px;
border-radius: 6px;
border: 1px solid #cbd5e1;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #94a3b8;
}
.drop-zone {
border: 2px dashed #cbd5e1;
border-radius: 8px;
padding: 36px 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: #fafafa;
transition: all 0.15s ease;
}
.drop-zone.dragover {
border-color: #2563eb;
background: #eff6ff;
}
.drop-icon {
color: #94a3b8;
}
.drop-title {
font-size: 14px;
font-weight: 500;
color: #475569;
margin: 0;
}
.file-input-hidden {
display: none;
}
.import-preview-box {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 20px;
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.preview-header h3 {
font-size: 15px;
font-weight: 600;
color: #050841;
margin: 0;
}
.badge-valid {
background: #dcfce7;
color: #15803d;
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 9999px;
}
.preview-metrics {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 14px;
margin-bottom: 18px;
}
:global(.metric-row) {
font-size: 13px;
display: flex;
justify-content: space-between;
color: #475569;
}
:global(.metric-row strong) {
color: #0f172a;
}
.mode-selection-group {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.mode-radio {
display: flex;
align-items: flex-start;
gap: 10px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 12px 14px;
cursor: pointer;
}
.mode-radio input {
margin-top: 3px;
}
.mode-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.mode-text strong {
font-size: 14px;
color: #0f172a;
}
.mode-text span {
font-size: 12px;
color: #64748b;
}
.preview-actions {
display: flex;
align-items: center;
gap: 10px;
}
.import-status-banner {
margin-top: 14px;
padding: 12px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
}
.status-info {
background: #eff6ff;
color: #1e40af;
border: 1px solid #bfdbfe;
}
.status-success {
background: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}
.status-error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.readonly-notice {
background: #fffbeb;
color: #92400e;
border: 1px solid #fef3c7;
padding: 12px 16px;
border-radius: 6px;
font-size: 13px;
}
</style>
+3
View File
@@ -27,6 +27,9 @@ if (pathname.startsWith("/app/inventory/")) {
} 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/";
+4 -1
View File
@@ -127,7 +127,10 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
{calculatePercent&&percentMode==="bakers"&&baseItems.length>0&&<section class="bakers-base-summary"><strong>Base</strong><div>{baseItems.map(item=>{const displayUnitId=lineUnits[item.id]??item.amount.unit_id;const displayQuantity=convertItem(item.amount.quantity*validFactor,item.amount.unit_id,displayUnitId,item,units);return <p><span><input aria-label={`${item.label} base quantity`} type="number" min="0" step="any" value={roundForDisplay(displayQuantity)} onInput={(event)=>{const canonicalQuantity=convertItem(Number(event.currentTarget.value),displayUnitId,item.amount.unit_id,item,units);setFactor(item.amount.quantity>0?canonicalQuantity/item.amount.quantity:1)}}/><select aria-label={`${item.label} base unit`} value={displayUnitId} onChange={(event)=>setLineUnits((current)=>({...current,[item.id]:event.currentTarget.value}))}>{itemUnits(item,units).map(unit=><option value={unit.id}>{unit.symbol}</option>)}</select></span><a href={item.href}>{item.label}</a></p>})}</div></section>}
{components.map((component) => (
<div class="formula-component" key={component.id}>
{components.length > 1 && <h3>{component.name}</h3>}
{Boolean(component.name?.trim()) && <h3 class="formula-component-heading">{component.name}</h3>}
{(component.notes ?? []).filter(Boolean).map((note, noteIdx) => (
<p key={noteIdx} class="formula-component-note">{note}</p>
))}
<div class="table-wrap">
<table class="recipe-ingredients">
<thead><tr><th>Amount</th><th>Ingredient</th>{calculatePercent&&<th>{percentMode==="standard"?"Standard %":"Baker's %"}</th>}</tr></thead>
+207
View File
@@ -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");
});
});
+284
View File
@@ -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,
},
};
}
+482
View File
@@ -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.`,
};
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./types.ts";
export * from "./export-database.ts";
export * from "./import-database.ts";
export * from "./validate-backup.ts";
+297
View File
@@ -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;
}
+110
View File
@@ -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,
};
}
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -11,7 +11,7 @@ import { databaseProjection, openDatabase } from "./database";
export function loadCatalogs() {
const database = openDatabase();
if (!database) throw new Error("Database unavailable. Run npm run db:reset first.");
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[];
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Amount, DensityMeasurement, Ingredient, Unit } from "./types";
import type { Amount, DensityMeasurement, Ingredient, Unit } from "./types.ts";
export type UnitCatalog = Map<string, Unit> | Record<string, Unit>;
+2 -2
View File
@@ -1,5 +1,5 @@
import type { Ingredient, Recipe, SourceMapping, Unit } from "./types";
import { convertWithIngredientMeasures } from "./measurement";
import type { Ingredient, Recipe, SourceMapping, Unit } from "./types.ts";
import { convertWithIngredientMeasures } from "./measurement.ts";
export type NutritionFacts = Record<string, number>;
export type NutritionResult = {
+353
View File
@@ -0,0 +1,353 @@
import { describe, expect, it } from "vitest";
import { openDatabase } from "../lib/database";
import { createMcpTools } from "./tools";
describe("MCP Tools & Domain Integration", () => {
it("searches recipes with keywords and filters", () => {
const db = openDatabase({ readOnly: true });
expect(db).toBeDefined();
if (!db) return;
try {
const tools = createMcpTools(() => db);
const all = tools.searchRecipes({ limit: 10 });
expect(all.length).toBeGreaterThan(0);
expect(all[0]).toHaveProperty("id");
expect(all[0]).toHaveProperty("title");
expect(all[0]).toHaveProperty("yield");
const filtered = tools.searchRecipes({ query: "biscotti" });
expect(filtered.length).toBeGreaterThan(0);
expect(filtered.some((r) => r.id === "chocolate_biscotti")).toBe(true);
} finally {
db.close();
}
});
it("retrieves full recipe with scaling calculation", () => {
const db = openDatabase({ readOnly: true });
expect(db).toBeDefined();
if (!db) return;
try {
const tools = createMcpTools(() => db);
const base = tools.getRecipe({ id: "chocolate_biscotti" });
expect(base.id).toBe("chocolate_biscotti");
expect(base.components.length).toBeGreaterThan(0);
expect(base.steps.length).toBeGreaterThan(0);
expect(base.scale_factor).toBe(1);
// Scale by 2x
const doubled = tools.getRecipe({ id: "chocolate_biscotti", scale_factor: 2 });
expect(doubled.scale_factor).toBe(2);
expect(doubled.yield.quantity).toBe(base.yield.quantity * 2);
expect(doubled.components[0].items[0].quantity).toBe(
base.components[0].items[0].base_quantity * 2
);
} finally {
db.close();
}
});
it("runs recipe quality audit", () => {
const db = openDatabase({ readOnly: true });
expect(db).toBeDefined();
if (!db) return;
try {
const tools = createMcpTools(() => db);
const audit = tools.auditRecipeQuality();
expect(audit.summary.total).toBeGreaterThan(0);
expect(audit.recipes.length).toBe(audit.summary.total);
expect(audit.recipes[0]).toHaveProperty("placeholder_steps");
expect(audit.recipes[0]).toHaveProperty("unpriced_items");
} finally {
db.close();
}
});
it("searches ingredients and retrieves ingredient detail", () => {
const db = openDatabase({ readOnly: true });
expect(db).toBeDefined();
if (!db) return;
try {
const tools = createMcpTools(() => db);
const ings = tools.searchIngredients({ query: "flour", limit: 5 });
expect(ings.length).toBeGreaterThan(0);
const flour = tools.getIngredient({ id: "flour_all_purpose" });
expect(flour.id).toBe("flour_all_purpose");
expect(flour.name.toLowerCase()).toContain("flour");
expect(Array.isArray(flour.density_measurements)).toBe(true);
expect(Array.isArray(flour.measure_conversions)).toBe(true);
} finally {
db.close();
}
});
it("creates, updates, and deletes an ingredient", () => {
const db = openDatabase({ readOnly: false });
expect(db).toBeDefined();
if (!db) return;
try {
const tools = createMcpTools(() => db);
const createRes = tools.saveIngredient({
name: "Test Matcha Powder",
description: "Ceremonial grade green tea powder",
categories: ["tea", "flavoring"],
tags: ["beverage", "japanese"],
aliases: [{ name: "Matcha", kind: "search" }],
density: {
mass_quantity: 60,
mass_unit_id: "gram",
volume_quantity: 0.25,
volume_unit_id: "cup_us",
},
});
expect(createRes.success).toBe(true);
expect(createRes.created).toBe(true);
const ingId = createRes.ingredient_id;
const fetched = tools.getIngredient({ id: ingId });
expect(fetched.name).toBe("Test Matcha Powder");
expect(fetched.categories).toContain("tea");
expect(fetched.density_measurements.length).toBe(1);
const updateRes = tools.saveIngredient({
id: ingId,
name: "Test Matcha Powder Organic",
categories: ["tea", "flavoring", "organic"],
});
expect(updateRes.success).toBe(true);
const deleteRes = tools.deleteIngredient({ id: ingId });
expect(deleteRes.success).toBe(true);
} finally {
db.close();
}
});
it("prevents deleting an ingredient that is in active use", () => {
const db = openDatabase({ readOnly: false });
expect(db).toBeDefined();
if (!db) return;
try {
const tools = createMcpTools(() => db);
expect(() => tools.deleteIngredient({ id: "sugar" })).toThrow(/used in active recipe/);
} finally {
db.close();
}
});
it("manages purchase items and records price observations", () => {
const db = openDatabase({ readOnly: false });
expect(db).toBeDefined();
if (!db) return;
try {
const tools = createMcpTools(() => db);
// Create purchase item with initial price
const createRes = tools.savePurchaseItem({
ingredient_id: "flour_all_purpose",
name: "King Arthur AP Flour 25 lb",
brand: "King Arthur",
supplier_id: "sysco",
package_quantity: 25,
package_unit_id: "pound",
initial_price: {
amount: 18.50,
currency: "USD",
},
});
expect(createRes.success).toBe(true);
const piId = createRes.purchase_item_id;
// Get detail
const detail = tools.getPurchaseItem({ id: piId });
expect(detail.name).toBe("King Arthur AP Flour 25 lb");
expect(detail.price_history.length).toBe(1);
expect(detail.price_history[0].amount).toBe(18.50);
// Record new price observation
const priceRes = tools.recordPriceObservation({
purchase_item_id: piId,
amount: 19.25,
currency: "USD",
effective_at: "2026-08-17",
});
expect(priceRes.success).toBe(true);
// Clean up
db.prepare("DELETE FROM price_observations WHERE purchase_item_id = ?").run(piId);
db.prepare("DELETE FROM purchase_items WHERE id = ?").run(piId);
} finally {
db.close();
}
});
it("lists reference catalogs: units, equipment, and prep actions", () => {
const db = openDatabase({ readOnly: true });
expect(db).toBeDefined();
if (!db) return;
try {
const tools = createMcpTools(() => db);
const units = tools.listUnits({ dimension: "mass" });
expect(units.length).toBeGreaterThan(0);
expect(units.every((u) => u.dimension === "mass")).toBe(true);
const equip = tools.listEquipment();
expect(equip.length).toBeGreaterThan(0);
expect(equip[0]).toHaveProperty("id");
expect(equip[0]).toHaveProperty("name");
const prep = tools.listPrepActions();
expect(prep.length).toBeGreaterThan(0);
expect(prep[0]).toHaveProperty("action_type");
} finally {
db.close();
}
});
it("creates, retrieves, and updates recipe books", () => {
const db = openDatabase({ readOnly: false });
expect(db).toBeDefined();
if (!db) return;
try {
const tools = createMcpTools(() => db);
const createRes = tools.saveRecipeBook({
name: "Test Holiday Pastries",
description: "Holiday baked goods collection",
recipe_ids: ["chocolate_biscotti", "cinnamon_sugar"],
});
expect(createRes.success).toBe(true);
const bookId = createRes.book_id;
const book = tools.getRecipeBook({ id: bookId });
expect(book.name).toBe("Test Holiday Pastries");
expect(book.recipe_count).toBe(2);
expect(book.recipes.length).toBe(2);
const list = tools.listRecipeBooks();
expect(list.some((b) => b.id === bookId)).toBe(true);
db.prepare("DELETE FROM collection_recipes WHERE collection_id = ?").run(bookId);
db.prepare("DELETE FROM collections WHERE id = ?").run(bookId);
} finally {
db.close();
}
});
it("creates, retrieves, and updates inventory count sessions", () => {
const db = openDatabase({ readOnly: false });
expect(db).toBeDefined();
if (!db) return;
try {
const tools = createMcpTools(() => db);
const createRes = tools.createInventoryCountSession({
title: "Test Week Count",
counted_at: "2026-08-17T12:00:00Z",
notes: "Automated test session",
prepopulate: true,
});
expect(createRes.success).toBe(true);
const countId = createRes.count_id;
const detail = tools.getInventoryCount({ id: countId });
expect(detail.id).toBe(countId);
expect(detail.status).toBe("open");
expect(detail.items.length).toBeGreaterThan(0);
const updateRes = tools.updateInventoryCount({
count_id: countId,
items: [{ ingredient_id: "sugar", quantity: 50, unit_id: "pound" }],
});
expect(updateRes.success).toBe(true);
db.prepare("DELETE FROM inventory_count_items WHERE count_id = ?").run(countId);
db.prepare("DELETE FROM inventory_counts WHERE id = ?").run(countId);
} finally {
db.close();
}
});
it("converts units within dimension and cross-dimension with density", () => {
const db = openDatabase({ readOnly: true });
expect(db).toBeDefined();
if (!db) return;
try {
const tools = createMcpTools(() => db);
const kgToG = tools.convertUnits({
quantity: 1,
from_unit: "kilogram",
to_unit: "gram",
});
expect(kgToG.to.quantity).toBe(1000);
const cupToG = tools.convertUnits({
ingredient_id: "salt",
quantity: 1,
from_unit: "cup_us",
to_unit: "gram",
});
expect(cupToG.to.quantity).toBeCloseTo(292, 0);
} finally {
db.close();
}
});
it("calculates recipe cost and nutrition", () => {
const db = openDatabase({ readOnly: true });
expect(db).toBeDefined();
if (!db) return;
try {
const tools = createMcpTools(() => db);
const cost = tools.calculateRecipeCost({ recipe_id: "chocolate_biscotti", currency: "USD" });
expect(cost).toHaveProperty("currency", "USD");
expect(cost).toHaveProperty("lines");
const nutrition = tools.calculateRecipeNutrition({ recipe_id: "chocolate_biscotti" });
expect(nutrition).toHaveProperty("batch");
expect(nutrition).toHaveProperty("inputWeightG");
} finally {
db.close();
}
});
it("lists archived items and exports database backup", () => {
const db = openDatabase({ readOnly: true });
expect(db).toBeDefined();
if (!db) return;
try {
const tools = createMcpTools(() => db);
const stats = tools.getDatabaseStats();
expect(stats.recipes).toBeGreaterThan(100);
expect(stats.ingredients).toBeGreaterThan(100);
const archived = tools.listArchivedItems();
expect(archived).toHaveProperty("total");
expect(Array.isArray(archived.recipes)).toBe(true);
const backup = tools.exportDatabaseBackup();
expect(backup.format_version).toBe("1.0.0");
expect(backup.summary.recipes_count).toBe(stats.recipes);
expect(backup.summary.ingredients_count).toBe(stats.ingredients);
} finally {
db.close();
}
});
});
+362
View File
@@ -0,0 +1,362 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { openDatabase } from "../lib/database.ts";
import { createMcpTools } from "./tools.ts";
export function createFormulationMcpServer() {
const server = new McpServer({
name: "formulation",
version: "2.0.0",
});
const getDb = () => {
const db = openDatabase({ readOnly: false });
if (!db) throw new Error("Formulation database unavailable at var/recipe-book.sqlite");
return db;
};
const tools = createMcpTools(getDb);
function wrap<T>(fn: () => T | Promise<T>) {
return async () => {
try {
const result = await fn();
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err: any) {
return { content: [{ type: "text" as const, text: `Error: ${err.message}` }], isError: true };
}
};
}
// 1. Search Recipes
server.tool(
"search_recipes",
"Search recipes in the Formulation culinary database by keyword, category, or tag.",
{
query: z.string().optional().describe("Search keywords for title or ingredient"),
category: z.string().optional().describe("Filter by category (e.g. 'sauce', 'bread')"),
tag: z.string().optional().describe("Filter by tag"),
limit: z.number().int().min(1).max(100).default(25).describe("Max results"),
},
async (args) => wrap(() => tools.searchRecipes(args))()
);
// 2. Get Recipe
server.tool(
"get_recipe",
"Get full recipe formulation with components, items, steps, and optional scaling.",
{
id: z.string().describe("Recipe ID"),
scale_factor: z.number().positive().optional().describe("Scale multiplier (e.g. 2 for double batch)"),
target_yield: z.number().positive().optional().describe("Target yield quantity to scale to"),
target_yield_unit: z.string().optional().describe("Target yield unit if different from recipe"),
},
async (args) => wrap(() => tools.getRecipe(args))()
);
// 3. Save Recipe
server.tool(
"save_recipe",
"Create or update a recipe with validated components, items, and preparation steps.",
{
id: z.string().optional().describe("Recipe ID (omit to create new)"),
title: z.string().min(1).describe("Recipe title"),
summary: z.string().nullable().optional().describe("Brief culinary summary"),
yield_quantity: z.number().positive().describe("Finished yield quantity"),
yield_unit_id: z.string().describe("Yield unit (e.g. 'gram', 'each')"),
yield_servings: z.number().positive().nullable().optional().describe("Number of servings"),
yield_basis: z.string().optional().describe("Yield basis: theoretical, measured, estimated"),
categories: z.array(z.string()).optional().default([]),
tags: z.array(z.string()).optional().default([]),
components: z.array(z.object({
id: z.string().optional(),
name: z.string().default("Main"),
items: z.array(z.object({
id: z.string().optional(),
ingredient_id: z.string().optional(),
subrecipe_id: z.string().optional(),
quantity: z.number().positive(),
unit_id: z.string(),
percentage: z.number().nullable().optional(),
basis_member: z.boolean().default(false),
optional: z.boolean().default(false),
notes: z.string().nullable().optional(),
})),
})).min(1).describe("Components with ingredient line items"),
steps: z.array(z.object({
id: z.string().optional(),
instruction: z.string().min(1),
equipment_ids: z.array(z.string()).optional(),
})).min(1).describe("Ordered preparation steps"),
notes: z.array(z.string()).optional().default([]),
},
async (args) => wrap(() => tools.saveRecipe(args as any))()
);
// 4. Delete Recipe
server.tool(
"delete_recipe",
"Archive a recipe from the active formulation library.",
{ id: z.string().describe("Recipe ID to archive") },
async (args) => wrap(() => tools.deleteRecipe(args))()
);
// 5. Recipe Quality Audit
server.tool(
"audit_recipe_quality",
"Run automated quality audit on all recipes (unpriced items, placeholder steps, missing yield basis).",
{},
async () => wrap(() => tools.auditRecipeQuality())()
);
// 6. Calculate Recipe Cost
server.tool(
"calculate_recipe_cost",
"Compute itemized ingredient costs, total batch cost, and cost per serving for a recipe.",
{
recipe_id: z.string().describe("Recipe ID"),
currency: z.string().default("USD").describe("Currency code"),
},
async (args) => wrap(() => tools.calculateRecipeCost(args))()
);
// 7. Calculate Recipe Nutrition
server.tool(
"calculate_recipe_nutrition",
"Calculate nutrition facts (macros, vitamins) per 100g and per serving.",
{
recipe_id: z.string().describe("Recipe ID"),
serving_size_g: z.number().positive().optional().describe("Serving size in grams"),
},
async (args) => wrap(() => tools.calculateRecipeNutrition(args))()
);
// 8. Search Ingredients
server.tool(
"search_ingredients",
"Search ingredients in the pantry catalog with cost and nutrition status.",
{
query: z.string().optional().describe("Search keywords"),
category: z.string().optional().describe("Filter by category"),
missing_cost: z.boolean().optional().describe("Show only ingredients missing purchase prices"),
limit: z.number().int().min(1).max(100).default(25).describe("Max results"),
},
async (args) => wrap(() => tools.searchIngredients(args))()
);
// 9. Get Ingredient
server.tool(
"get_ingredient",
"Get detailed ingredient with density, equivalencies, aliases, and purchase prices.",
{ id: z.string().describe("Ingredient ID") },
async (args) => wrap(() => tools.getIngredient(args))()
);
// 10. Save Ingredient
server.tool(
"save_ingredient",
"Create or update an ingredient with category, aliases, and density data.",
{
id: z.string().optional().describe("Ingredient ID (omit to create new from name)"),
name: z.string().min(1).describe("Ingredient name"),
description: z.string().nullable().optional().describe("Culinary description"),
categories: z.array(z.string()).optional().default([]),
tags: z.array(z.string()).optional().default([]),
aliases: z.array(z.object({ name: z.string(), kind: z.string().optional() })).optional(),
density: z.object({
mass_quantity: z.number().positive(),
mass_unit_id: z.string(),
volume_quantity: z.number().positive(),
volume_unit_id: z.string(),
}).optional().describe("Density measurement for volume-to-weight conversions"),
},
async (args) => wrap(() => tools.saveIngredient(args))()
);
// 11. Delete Ingredient
server.tool(
"delete_ingredient",
"Archive an ingredient (fails safely if used in active recipes).",
{ id: z.string().describe("Ingredient ID to archive") },
async (args) => wrap(() => tools.deleteIngredient(args))()
);
// 12. Purchasing & Prices
server.tool(
"list_purchase_items",
"List purchase items with latest price observations.",
{
ingredient_id: z.string().optional().describe("Filter by ingredient ID"),
status: z.string().optional().describe("Filter by status (active, discontinued)"),
limit: z.number().int().min(1).max(200).default(50).describe("Max results"),
},
async (args) => wrap(() => tools.listPurchaseItems(args))()
);
server.tool(
"get_purchase_item",
"Get purchase item with full price history observations.",
{ id: z.string().describe("Purchase Item ID") },
async (args) => wrap(() => tools.getPurchaseItem(args))()
);
server.tool(
"save_purchase_item",
"Create or update a purchase item with package specification and initial price.",
{
id: z.string().optional().describe("Purchase Item ID"),
ingredient_id: z.string().describe("Target ingredient ID"),
name: z.string().min(1).describe("Product package name"),
brand: z.string().nullable().optional(),
supplier_id: z.string().nullable().optional(),
supplier_sku: z.string().nullable().optional(),
package_quantity: z.number().positive().describe("Package quantity"),
package_unit_id: z.string().describe("Package unit ID (e.g. 'pound', 'gram')"),
units_per_case: z.number().positive().default(1),
usable_yield_factor: z.number().positive().default(1),
initial_price: z.object({
amount: z.number().positive(),
currency: z.string().default("USD"),
effective_at: z.string().optional(),
}).optional(),
},
async (args) => wrap(() => tools.savePurchaseItem(args as any))()
);
server.tool(
"record_price_observation",
"Record a new price observation for an existing purchase item.",
{
purchase_item_id: z.string().describe("Purchase Item ID"),
amount: z.number().positive().describe("Price amount"),
currency: z.string().default("USD").describe("Currency code"),
effective_at: z.string().optional().describe("Effective date (YYYY-MM-DD)"),
},
async (args) => wrap(() => tools.recordPriceObservation(args))()
);
// 13. Convert Units
server.tool(
"convert_units",
"Convert culinary units using ingredient-specific density and equivalency data.",
{
ingredient_id: z.string().optional().describe("Ingredient ID for cross-dimension conversion"),
quantity: z.number().positive().describe("Quantity to convert"),
from_unit: z.string().describe("Source unit ID (e.g. 'cup_us', 'gram')"),
to_unit: z.string().describe("Target unit ID (e.g. 'gram', 'oz')"),
},
async (args) => wrap(() => tools.convertUnits(args))()
);
// 14. Reference Catalogs
server.tool(
"list_units",
"List all measurement units (mass, volume, count, time, temperature) with base conversion factors.",
{
dimension: z.string().optional().describe("Filter by dimension (mass, volume, count, time, temperature, length)"),
system: z.string().optional().describe("Filter by system (metric, customary, si)"),
},
async (args) => wrap(() => tools.listUnits(args))()
);
server.tool(
"list_equipment",
"List kitchen equipment items with categories and notes.",
{ category: z.string().optional().describe("Filter by category") },
async (args) => wrap(() => tools.listEquipment(args))()
);
server.tool(
"list_prep_actions",
"List culinary prep actions (peel, trim, chop) and default yield factors.",
{},
async () => wrap(() => tools.listPrepActions())()
);
// 15. Inventory Counts
server.tool(
"list_inventory_counts",
"List inventory counting sessions with status and valuations.",
{ status: z.enum(["all", "open", "completed"]).default("all").describe("Filter by status") },
async (args) => wrap(() => tools.listInventoryCounts(args))()
);
server.tool(
"get_inventory_count",
"Get full inventory count sheet with counted items, locations, unit costs, and valuations.",
{ id: z.string().describe("Inventory Count ID") },
async (args) => wrap(() => tools.getInventoryCount(args))()
);
// 16. Recipe Books / Collections
server.tool(
"list_recipe_books",
"List recipe books (collections) with included recipe counts.",
{},
async () => wrap(() => tools.listRecipeBooks())()
);
server.tool(
"get_recipe_book",
"Get recipe book details with ordered included recipes.",
{ id: z.string().describe("Recipe Book ID") },
async (args) => wrap(() => tools.getRecipeBook(args))()
);
server.tool(
"save_recipe_book",
"Create or update a recipe book with name, description, and included recipe IDs.",
{
id: z.string().optional().describe("Recipe book ID"),
name: z.string().min(1).describe("Recipe book name"),
description: z.string().nullable().optional().describe("Description"),
recipe_ids: z.array(z.string()).optional().describe("Ordered list of recipe IDs"),
},
async (args) => wrap(() => tools.saveRecipeBook(args))()
);
// 17. Archive & Restore
server.tool(
"list_archived_items",
"List all archived (soft-deleted) items across recipes, ingredients, purchases, books, and inventory.",
{},
async () => wrap(() => tools.listArchivedItems())()
);
server.tool(
"restore_archived_items",
"Restore archived items by ID and type (recipe, ingredient, purchase, book, inventory).",
{
items: z.array(z.object({
id: z.string(),
type: z.enum(["recipe", "ingredient", "purchase", "book", "inventory"]),
})).min(1).describe("List of items to restore"),
},
async (args) => wrap(() => tools.restoreArchived(args))()
);
// 18. Database Export & Stats
server.tool(
"export_database_backup",
"Export a complete JSON backup of the Formulation database (all 25 tables).",
{},
async () => wrap(() => tools.exportDatabaseBackup())()
);
server.tool(
"get_database_stats",
"Get entity count statistics across all database tables.",
{},
async () => wrap(() => tools.getDatabaseStats())()
);
return server;
}
export async function runStdioServer() {
const server = createFormulationMcpServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Formulation MCP Server running on stdio");
}
+1063
View File
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -41,14 +41,13 @@
--ink-secondary: #3C4679;
--muted: #8283a0;
--muted-light: #a5a9c1;
--muted-subtle: #757677;
--paper: #f3f3f3;
--paper: #ffffff;
--card: #ffffff;
--card-alt: #fbfbfb;
--card-alt: #fafbfd;
--card-paper: #f1f5fe;
--line: #ececec;
--line-subtle: #f3f3f3;
--line-focus: #dadada;
--line: #edf0f5;
--line-subtle: #f4f5f8;
--line-focus: #3d5df6;
--blue: #3d5df6;
--blue-hover: #1236e1;
--blue-active: #001992;
@@ -152,7 +151,7 @@ a { color: inherit; text-decoration-thickness: 1px; text-underline-offset: .2em;
display: flex;
align-items: center;
gap: 8px;
background: #fbfbfb;
background: #ffffff;
border: 1px solid #ececec;
border-radius: 4px;
padding: 4px 10px;
+120 -69
View File
@@ -43,14 +43,13 @@
--ink-secondary: #3C4679;
--muted: #8283a0;
--muted-light: #a5a9c1;
--muted-subtle: #757677;
--paper: #f3f3f3;
--paper: #ffffff;
--card: #ffffff;
--card-alt: #fbfbfb;
--card-alt: #fafbfd;
--card-paper: #f1f5fe;
--line: #ececec;
--line-subtle: #f3f3f3;
--line-focus: #dadada;
--line: #edf0f5;
--line-subtle: #f4f5f8;
--line-focus: #3d5df6;
--blue: #3d5df6;
--blue-hover: #1236e1;
--blue-active: #001992;
@@ -252,28 +251,28 @@ th { font-size:.7rem; letter-spacing:.06em; }
.ingredient-purchase-form > header button { padding:5px 7px; color:#f63d48; background:transparent; border:0; cursor:pointer; font-size:11px; }
.ingredient-purchase-form .purchase-item-form { margin:0; padding:0; background:#fff; border:0; border-radius:0; }
.ingredient-purchase-form .purchase-item-form label { color:#3c4679; font-size:11px; font-weight:500; letter-spacing:0; text-transform:none; }
.ingredient-purchase-form .purchase-item-form input,.ingredient-purchase-form .purchase-item-form select { min-height:44px; padding:9px 10px; background:#fbfbfb; border-color:#ececec; border-radius:0; }
.ingredient-purchase-form .purchase-item-form input,.ingredient-purchase-form .purchase-item-form select { min-height:44px; padding:9px 10px; background:#ffffff; border-color:#ececec; border-radius:0; }
.ingredient-purchase-form .purchase-form-actions button { padding:9px 16px; background:#3d5df6; border-color:#3d5df6; border-radius:20px; }
.ingredient-cost-row { display:flex; align-items:center; justify-content:space-between; gap:20px; min-height:68px; padding:12px 0; border-bottom:1px solid #f3f3f3; }
.ingredient-cost-row { display:flex; align-items:center; justify-content:space-between; gap:20px; min-height:68px; padding:12px 0; border-bottom:1px solid #edf0f5; }
.ingredient-cost-row > span:last-child { text-align:right; }
.ingredient-cost-row strong,.ingredient-cost-row small { display:block; }
.ingredient-cost-row small { margin-top:3px; color:#a5a9c1; font-size:11px; }
.detail-actions-menu label { display:grid; gap:6px; padding:9px 14px; color:#a5a9c1; font-size:11px; }
.detail-actions-menu select { width:250px; padding:9px; }
.application-body .ingredient-actions-menu > div { width:290px; }
@media (min-width:981px) { .application-body .entity-detail-shell .entity-primary { background:#fbfbfb; } }
.application-body .entity-detail-shell .entity-detail-header { background:#fbfbfb; }
@media (min-width:981px) { .application-body .entity-detail-shell .entity-primary { background:#ffffff; } }
.application-body .entity-detail-shell .entity-detail-header { background:#ffffff; }
.application-body .entity-detail-shell .entity-primary { padding-top:35px; }
.application-body .entity-detail-shell .entity-section { margin-bottom:35px; }
.application-body .entity-detail-shell .entity-section h2,.application-body .entity-detail-shell .entity-tab-panel h2 { color:#050841; font-size:22px; font-weight:700; line-height:1.25; }
.application-body .entity-detail-shell .panel-intro { max-width:640px; margin:5px 0 20px; color:#95969c; font-size:14px; line-height:1.5; }
.application-body .entity-detail-shell .entity-tabs { position:relative; min-height:58px; background:#fff; border-color:#f3f3f3; }
.application-body .entity-detail-shell .entity-tabs { position:relative; min-height:58px; background:#fff; border-color:#edf0f5; }
.application-body .entity-detail-shell .entity-tabs button { min-height:58px; padding:8px 12px; color:#050841; font-size:15px; font-weight:400; }
.application-body .entity-detail-shell .entity-tabs button.active { color:#050841; background:#f1f5fe; border-bottom:2px solid #3d5df6; }
.application-body .entity-detail-shell .entity-tab-panel { padding:35px 2px 60px; }
.application-body .entity-detail-shell .prep-action-table { width:100%; margin-top:26px; table-layout:fixed; }
.application-body .entity-detail-shell .prep-action-table th { height:40px; padding:8px 10px; color:#757677; background:#fbfbfb; border-color:#f3f3f3; font-size:12px; }
.application-body .entity-detail-shell .prep-action-table td { height:58px; padding:10px; color:#050841; background:#fff; border:1px solid #f3f3f3; font-size:14px; }
.application-body .entity-detail-shell .prep-action-table th { height:40px; padding:8px 10px; color:#757677; background:#ffffff; border-color:#edf0f5; font-size:12px; }
.application-body .entity-detail-shell .prep-action-table td { height:58px; padding:10px; color:#050841; background:#fff; border:1px solid #edf0f5; font-size:14px; }
.application-body .entity-detail-shell .inline-editor > summary,.application-body .entity-detail-shell .add-purchase > summary { display:inline-flex; align-items:center; min-height:40px; padding:8px 16px; color:#202962; background:#fff; border:1px solid #202962; border-radius:22px; font-size:14px; font-weight:600; list-style:none; }
.application-body .entity-detail-shell .inline-editor > summary::-webkit-details-marker,.application-body .entity-detail-shell .add-purchase > summary::-webkit-details-marker { display:none; }
.application-body .entity-detail-shell .inline-form { gap:10px; margin-top:18px; padding-top:18px; border-color:#f3f3f3; }
@@ -375,13 +374,13 @@ th { font-size:.7rem; letter-spacing:.06em; }
.application-body {
--ink:#050841;
--muted:#a5a9c1;
--paper:#f3f3f3;
--paper:#ffffff;
--card:#fff;
--line:#eeeeF3;
--line:#edf0f5;
--green:#3d5df6;
--orange:#647df8;
color:#050841;
background:#f3f3f3;
background:#ffffff;
font-family:var(--meez-font-sans);
font-size:16px;
font-weight:300;
@@ -396,10 +395,10 @@ th { font-size:.7rem; letter-spacing:.06em; }
/* Home workspace */
.application-body .directory-workspace { width:min(1120px,calc(100% - 48px)); padding:26px 0 32px; }
.application-body .workspace-search-tools { position:sticky; top:0; z-index:20; display:flex; align-items:center; min-height:80px; margin:0; background:#fbfbfb; }
.application-body .workspace-search-tools { position:sticky; top:0; z-index:20; display:flex; align-items:center; min-height:80px; margin:0; background:#ffffff; }
.application-body .workspace-search-tools-inner { width:min(1120px,calc(100% - 48px)); margin-inline:auto; display:flex; align-items:center; justify-content:flex-end; gap:12px; }
.application-body .workspace-search-tools-inner .workspace-new-menu { margin-left:0; }
.application-body .workspace-global-search { flex:0 1 504px; min-height:40px; padding:0 12px; display:flex; align-items:center; gap:5px; background:#fff; border:1px solid #f3f3f3; border-radius:4px; box-shadow:none; }
.application-body .workspace-global-search { flex:0 1 504px; min-height:40px; padding:0 12px; display:flex; align-items:center; gap:5px; background:#fff; border:1px solid #edf0f5; border-radius:4px; box-shadow:none; }
.application-body .workspace-global-search .search-icon { flex:none; color:#8283a0; }
.application-body .workspace-global-search:focus-within { box-shadow:none; }
.application-body form.workspace-global-search input[type="search"] { flex:1; min-width:0; padding:8px 0 6px; color:rgba(0,0,0,0.87); background:transparent; border:0; border-radius:0; font-size:16px; font-weight:400; }
@@ -478,7 +477,7 @@ input[type="search"]::-webkit-search-results-decoration,
.application-body .workspace-pill-icon.book { background:#f3a642; }
.application-body .workspace-pills .filter-menu summary { display:inline-flex; align-items:center; gap:10px; min-height:44px; padding:10px 16px; color:rgba(0,0,0,0.87); background:transparent; border:0; border-radius:20px; font-size:16px; font-weight:400; }
.application-body .entity-directory-table { overflow:visible; border:0; background:#fff; }
.application-body .entity-directory-toolbar { grid-template-columns:36px 64px minmax(0,1fr) auto; gap:8px; min-height:57px; padding:0; background:#fbfbfb; border-bottom:1px solid #f3f3f3; }
.application-body .entity-directory-toolbar { grid-template-columns:36px 64px minmax(0,1fr) auto; gap:8px; min-height:57px; padding:0; background:#ffffff; border-bottom:1px solid #edf0f5; }
.application-body .entity-directory-toolbar.has-selection { grid-template-columns:36px minmax(0,1fr); }
.application-body .entity-directory-selection-bar { display:flex; align-items:center; justify-content:space-between; width:100%; padding-right:8px; }
.application-body .selection-count { color:#3d5df6; font-size:15px; font-weight:600; }
@@ -491,7 +490,7 @@ input[type="search"]::-webkit-search-results-decoration,
.application-body .bulk-action-btn.bulk-clear:hover { background:#f4f6fa; border-color:#a5a9c1; color:#050841; }
.application-body .entity-directory-head > span { font-size:14px; font-weight:400; color:#a5a9c1; text-transform:none; }
.application-body .entity-directory-head > span:nth-child(2) { text-align:left; padding-left:2px; }
.application-body .entity-directory-row,.application-body .entity-directory-row.columns-1,.application-body .entity-directory-row.columns-2,.application-body .entity-directory-row.columns-3 { grid-template-columns:36px 64px minmax(14rem,1fr) 36px; gap:8px; min-height:57px; padding:0; border-bottom:1px solid #f3f3f3; }
.application-body .entity-directory-row,.application-body .entity-directory-row.columns-1,.application-body .entity-directory-row.columns-2,.application-body .entity-directory-row.columns-3 { grid-template-columns:36px 64px minmax(14rem,1fr) 36px; gap:8px; min-height:57px; padding:0; border-bottom:1px solid #edf0f5; }
.application-body .entity-directory-table.read-only .entity-directory-row { grid-template-columns:30px minmax(0,1fr); }
.application-body .entity-directory-row.selected { background:#f1f5fe; }
.application-body .entity-directory-name a,.application-body .entity-directory-name strong { color:#050841; font-size:15px; font-weight:500; }
@@ -536,7 +535,7 @@ input[type="search"]::-webkit-search-results-decoration,
/* Full-width entity workspaces */
.application-body .immersive-main { color:#050841; background:#fff; }
.application-body .detail-utility { min-height:58px; padding:8px 28px; background:#fbfbfb; border-color:#f3f3f3; }
.application-body .detail-utility { min-height:58px; padding:8px 28px; background:#ffffff; border-color:#edf0f5; }
.application-body .detail-utility input::placeholder { color:#a5a9c1; }
.application-body .detail-utility > a { color:#202962; font-size:13px; font-weight:500; }
.application-body .immersive-main .entity-detail-header { min-height:164px; padding:25px 38px; border-color:#eeeef3; }
@@ -547,7 +546,7 @@ input[type="search"]::-webkit-search-results-decoration,
.application-body .immersive-main .entity-detail-grid,.application-body .immersive-main .recipe-structure-workspace .structure-editor { border-color:#eeeef3; }
.application-body .immersive-main .entity-primary { padding:42px 38px; border-color:#eeeef3; }
.application-body .immersive-main .entity-secondary { padding:0 32px; }
.application-body .entity-tabs { min-height:58px; border-color:#f3f3f3; }
.application-body .entity-tabs { min-height:58px; border-color:#edf0f5; }
.application-body .entity-tabs button,.application-body .recipe-workspace-tabs button { padding:17px 10px; color:#202962; font-size:15px; font-weight:400; }
.application-body .entity-tabs button.active,.application-body .recipe-workspace-tabs button.active { color:#050841; background:#f1f5fe; border-bottom:2px solid #3d5df6; }
.application-body .entity-tab-panel { padding-top:38px; }
@@ -798,7 +797,7 @@ input[type="search"]::-webkit-search-results-decoration,
.application-body .detail-actions-menu button { display:block; width:100% !important; max-width:100% !important; min-height:44px; padding:10px 20px; color:#050841; background:transparent; border:0; border-radius:0; cursor:pointer; font-size:15px; font-weight:500; text-align:left; white-space:nowrap; box-sizing:border-box; }
.application-body .detail-actions-menu button:hover { background:#f1f5fe; }
/* Recipe Books */
.application-body .recipe-book-page { min-height: 100vh; background: #f3f3f3; padding-top: 48px; }
.application-body .recipe-book-page { min-height: 100vh; background: #ffffff; padding-top: 48px; }
.application-body .recipe-book-workspace { width: min(1120px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 64px; }
.application-body .recipe-book-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 28px; }
.application-body .recipe-book-breadcrumbs { margin-bottom: 8px; }
@@ -815,13 +814,13 @@ input[type="search"]::-webkit-search-results-decoration,
.application-body .book-action-btn.active { color: #687086; }
.application-body .book-notice { margin-bottom: 20px; padding: 12px 16px; background: #fff1f1; border: 1px solid #f5cece; border-radius: 6px; color: #8d2e2e; font-size: 14px; }
.application-body .book-directory-table { background: #fff; border: 1px solid #eef0f5; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
.application-body .book-directory-toolbar { display: flex; align-items: center; justify-content: space-between; min-height: 48px; padding: 0 20px; background: #fbfbfb; border-bottom: 1px solid #f3f3f3; }
.application-body .book-directory-toolbar { display: flex; align-items: center; justify-content: space-between; min-height: 48px; padding: 0 20px; background: #ffffff; border-bottom: 1px solid #edf0f5; }
.application-body .book-toolbar-title { color: #050841; font-size: 14px; font-weight: 600; }
.application-body .book-toolbar-count { color: #8b93a7; font-size: 13px; font-weight: 400; }
.application-body .book-recipe-list { display: flex; flex-direction: column; }
.application-body .book-recipe-row { display: grid; grid-template-columns: 36px minmax(0, 1fr) 36px; align-items: center; gap: 12px; min-height: 58px; padding: 0 20px; border-bottom: 1px solid #f3f3f3; transition: background-color 0.12s ease; }
.application-body .book-recipe-row { display: grid; grid-template-columns: 36px minmax(0, 1fr) 36px; align-items: center; gap: 12px; min-height: 58px; padding: 0 20px; border-bottom: 1px solid #edf0f5; transition: background-color 0.12s ease; }
.application-body .book-recipe-row:last-child { border-bottom: 0; }
.application-body .book-recipe-row:hover { background: #fbfbfb; }
.application-body .book-recipe-row:hover { background: #f8faff; }
.application-body .book-recipe-icon { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: #3c4679; border-radius: 50%; color: #fff; }
.application-body .book-recipe-title { color: #050841; font-size: 15px; font-weight: 500; text-decoration: none; transition: color 0.12s ease; }
.application-body .book-recipe-title:hover { color: #3d5df6; }
@@ -846,7 +845,7 @@ input[type="search"]::-webkit-search-results-decoration,
.application-body .book-input, .application-body .book-textarea { width: 100%; padding: 10px 14px; background: #fff; border: 1px solid #dfe3ec; border-radius: 4px; color: #050841; font-family: var(--meez-font-sans); font-size: 15px; font-weight: 400; outline: none; box-sizing: border-box; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
.application-body .book-input:focus, .application-body .book-textarea:focus { border-color: #3d5df6; box-shadow: 0 0 0 2px rgba(61, 93, 246, 0.15); }
.application-body .book-recipe-checklist { display: flex; flex-direction: column; gap: 4px; max-height: 480px; overflow-y: auto; padding-right: 4px; }
.application-body .book-checklist-item { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 8px 14px; background: #fff; border: 1px solid #f3f3f3; border-radius: 6px; cursor: pointer; transition: background 0.12s ease, border-color 0.12s ease; }
.application-body .book-checklist-item { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 8px 14px; background: #fff; border: 1px solid #edf0f5; border-radius: 6px; cursor: pointer; transition: background 0.12s ease, border-color 0.12s ease; }
.application-body .book-checklist-item:hover { background: #f7f9fd; border-color: #e4e7ed; }
.application-body .book-checklist-item:has(.book-checkbox:checked) { background: #f1f5fe; border-color: #cfd5fa; }
.application-body .book-checkbox { width: 18px; height: 18px; margin: 0; accent-color: #3d5df6; cursor: pointer; }
@@ -880,7 +879,7 @@ input[type="search"]::-webkit-search-results-decoration,
.application-body .workspace-pills > a.archive-link { margin-left:auto; color:#a5a9c1; background:transparent; border-color:transparent; }
/* Archive Workspace */
.application-body .archive-page { min-height: 100vh; background: #f3f3f3; padding-top: 48px; }
.application-body .archive-page { min-height: 100vh; background: #ffffff; padding-top: 48px; }
.application-body .archive-workspace { width: min(1120px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 64px; }
.application-body .archive-header { margin-bottom: 24px; }
.application-body .archive-breadcrumbs { margin-bottom: 8px; }
@@ -894,10 +893,10 @@ input[type="search"]::-webkit-search-results-decoration,
.application-body .archive-chip.active { background: #DBE4FF; border-color: #DBE4FF; }
.application-body .archive-chip .chip-count { color: #8b93a7; font-size: 12px; font-weight: 500; }
.application-body .archive-table { background: #fff; border: 1px solid #eef0f5; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
.application-body .archive-table-head { display: grid; grid-template-columns: 56px minmax(0, 1fr) 180px 120px; align-items: center; min-height: 48px; padding: 0 20px; background: #fbfbfb; border-bottom: 1px solid #f3f3f3; color: #8b93a7; font-size: 13px; font-weight: 500; }
.application-body .archive-table-row { display: grid; grid-template-columns: 56px minmax(0, 1fr) 180px 120px; align-items: center; min-height: 58px; padding: 0 20px; border-bottom: 1px solid #f3f3f3; transition: background-color 0.12s ease; }
.application-body .archive-table-head { display: grid; grid-template-columns: 56px minmax(0, 1fr) 180px 120px; align-items: center; min-height: 48px; padding: 0 20px; background: #ffffff; border-bottom: 1px solid #edf0f5; color: #8b93a7; font-size: 13px; font-weight: 500; }
.application-body .archive-table-row { display: grid; grid-template-columns: 56px minmax(0, 1fr) 180px 120px; align-items: center; min-height: 58px; padding: 0 20px; border-bottom: 1px solid #edf0f5; transition: background-color 0.12s ease; }
.application-body .archive-table-row:last-child { border-bottom: 0; }
.application-body .archive-table-row:hover { background: #fbfbfb; }
.application-body .archive-table-row:hover { background: #f8faff; }
.application-body .archive-type-cell { display: flex; align-items: center; }
.application-body .archive-name-cell { display: flex; flex-direction: column; min-width: 0; }
.application-body .archive-item-title { color: #050841; font-size: 15px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -908,7 +907,7 @@ input[type="search"]::-webkit-search-results-decoration,
.application-body .empty-icon-circle { display: grid; place-items: center; width: 56px; height: 56px; margin-bottom: 16px; background: #f1f5fe; border-radius: 50%; color: #3d5df6; }
/* Purchasing Review Tool */
.application-body .purchasing-review-page { min-height: 100vh; background: #f3f3f3; padding-top: 48px; }
.application-body .purchasing-review-page { min-height: 100vh; background: #ffffff; padding-top: 48px; }
.application-body .purchasing-review-workspace { width: min(1120px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 64px; }
.application-body .purchasing-review-header { margin-bottom: 24px; }
.application-body .purchasing-breadcrumbs { margin-bottom: 8px; }
@@ -922,7 +921,7 @@ input[type="search"]::-webkit-search-results-decoration,
.application-body .stats-label { color: #050841; font-size: 14px; font-weight: 500; }
.application-body .stats-hint { color: #8b93a7; font-size: 12px; }
.application-body .purchasing-review-actions { display: flex; align-items: center; gap: 12px; }
.application-body .purchasing-search-box { display: flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 12px; background: #fbfbfb; border: 1px solid #dfe3ec; border-radius: 4px; }
.application-body .purchasing-search-box { display: flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 12px; background: #ffffff; border: 1px solid #dfe3ec; border-radius: 4px; }
.application-body .purchasing-search-box .search-icon { color: #8283a0; flex: none; }
.application-body .purchasing-search-box input { border: 0; background: transparent; outline: none; font-family: var(--meez-font-sans); font-size: 14px; color: #050841; min-width: 180px; }
.application-body .search-clear-btn { background: transparent; border: 0; color: #a5a9c1; font-size: 18px; cursor: pointer; padding: 0 2px; }
@@ -1163,8 +1162,13 @@ input[type="search"]::-webkit-search-results-decoration,
color: #050841;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.application-body .unified-recipe-editor .editor-items tr.component-header-row,
.application-body .unified-recipe-editor .editor-items tr.component-header-row:hover,
.application-body .unified-recipe-editor .editor-items tr.component-header-row:focus-within {
background: transparent !important;
}
.application-body .unified-recipe-editor .editor-items tr.component-header-row td {
padding: 16px 0 6px;
padding: 18px 0 6px;
border-bottom: 0;
background: transparent;
}
@@ -1173,15 +1177,17 @@ input[type="search"]::-webkit-search-results-decoration,
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 44px;
padding: 4px 12px;
background: #f3f3f3;
border-radius: 8px;
transition: background-color 0.15s ease;
min-height: 38px;
padding: 4px 0;
background: transparent;
border-bottom: 1px solid #edf0f5;
border-radius: 0;
transition: border-color 0.15s ease;
}
.application-body .unified-recipe-editor .component-header-content:hover,
.application-body .unified-recipe-editor .component-header-content:focus-within {
background: #ebedf2;
background: transparent;
border-bottom-color: #3d5df6;
}
.application-body .unified-recipe-editor .component-name-input {
width: 100%;
@@ -1933,9 +1939,9 @@ input[type="search"]::-webkit-search-results-decoration,
.bulk-ingredient-dialog .bulk-submit:disabled { background:#aeb9f8; cursor:not-allowed; }
.bulk-prep-dialog { width:min(860px,100%); }
.bulk-prep-dialog textarea { min-height:390px; }
.application-body .unified-recipe-editor .method-editor li.prep-heading { min-height:70px; padding-block:18px; background:#fbfbfd; }
.application-body .unified-recipe-editor .method-editor li.prep-heading { min-height:70px; padding-block:18px; background:#ffffff; }
.application-body .unified-recipe-editor .method-editor li.prep-heading textarea { min-height:34px; font-weight:700; }
.application-body .unified-recipe-editor .method-editor li.prep-note { min-height:76px; background:#fafbfe; }
.application-body .unified-recipe-editor .method-editor li.prep-note { min-height:76px; background:#ffffff; }
.application-body .unified-recipe-editor .method-editor li.prep-note textarea { min-height:44px; color:#727a91; font-style:italic; }/* Recipe edit title and breadcrumb refinements. */
.application-body .editable-entity-title {
margin: 0;
@@ -1957,10 +1963,10 @@ input[type="search"]::-webkit-search-results-decoration,
}
/* Persistent detail breadcrumbs. */
.application-body .immersive-main .detail-utility { position:absolute; z-index:30; top:0; left:0; right:0; width:100%; height:48px; min-height:48px; display:grid; grid-template-columns:50% 50%; gap:0; padding:0; background:#fbfbfb; border-bottom:1px solid #f3f3f3; }
.application-body .immersive-main .detail-utility { position:absolute; z-index:30; top:0; left:0; right:0; width:100%; height:48px; min-height:48px; display:grid; grid-template-columns:50% 50%; gap:0; padding:0; background:#ffffff; border-bottom:1px solid #edf0f5; }
.application-body .detail-context,.application-body .detail-tools { display:flex; align-items:center; min-width:0; height:48px; }
.application-body .detail-context { gap:14px; padding:0 36px; }
.application-body .detail-tools { gap:18px; padding:0 32px; border-left:1px solid #f3f3f3; }
.application-body .detail-tools { gap:18px; padding:0 32px; border-left:1px solid #edf0f5; }
.application-body .detail-tools form { flex:1; width:auto; }
.application-body .detail-back { display:grid; place-items:center; width:30px; height:30px; color:#202962; text-decoration:none; font-size:30px; font-weight:400; line-height:1; }
.application-body .detail-back:hover { color:#3d5df6; }
@@ -2054,7 +2060,7 @@ input[type="search"]::-webkit-search-results-decoration,
/* Recipe read view — meez skin (measured live 2026-08-14). */
.application-body .immersive-main { background:#f3f3f3; }
.application-body .immersive-main { background:#ffffff; }
.application-body .recipe-detail-shell.recipe-read-shell {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@@ -2064,7 +2070,7 @@ input[type="search"]::-webkit-search-results-decoration,
min-height: auto;
margin: 0;
padding: 0;
background: #fbfbfb;
background: #ffffff;
}
.application-body .recipe-read-left {
width: 100%;
@@ -2099,13 +2105,13 @@ input[type="search"]::-webkit-search-results-decoration,
.application-body .recipe-read-right {
width: 100%;
max-width: 100%;
background: #fbfbfb;
background: #ffffff;
box-sizing: border-box;
}
.application-body .recipe-read-right .recipe-workspace-tabs {
min-height: 48px;
padding: 0 16px;
background: #fbfbfb;
background: #ffffff;
border-bottom: 1px solid #edf0f5;
}
.application-body .recipe-read-right .recipe-view-details {
@@ -2113,7 +2119,7 @@ input[type="search"]::-webkit-search-results-decoration,
background: transparent;
border-left: 0;
}
.application-body .recipe-detail-shell .entity-detail-header { background:#fbfbfb; }
.application-body .recipe-detail-shell .entity-detail-header { background:#ffffff; }
.application-body .recipe-detail-shell > .entity-detail-header > div:first-child > p { display:block; }
.application-body .recipe-detail-shell .entity-breadcrumb a { color:#202962; font-size:12px; font-weight:400; text-decoration:none; }
.application-body .recipe-detail-shell .entity-detail-header h1 { font-size:28px; font-weight:900; line-height:42px; }
@@ -2125,8 +2131,8 @@ input[type="search"]::-webkit-search-results-decoration,
/* Tab row: 48px, icon + 15px label, active = #f1f5fe with blue underline. */
.application-body .recipe-workspace-tabs {
min-height: 48px;
background: #fbfbfb;
border-bottom: 1px solid #f3f3f3;
background: #ffffff;
border-bottom: 1px solid #edf0f5;
justify-content: flex-start;
}
.application-body .recipe-workspace-tabs button {
@@ -2257,10 +2263,10 @@ input[type="search"]::-webkit-search-results-decoration,
display: grid;
}
/* Two-column workspace: #fbfbfb cards on #f3f3f3. */
.application-body .recipe-view-workspace { grid-template-columns:1fr 1fr; border-top:0; background:#fbfbfb; }
.application-body .recipe-view-formula { padding:26px 36px 35px; border-right:0; background:#fbfbfb; }
.application-body .recipe-view-details { padding:26px 32px 60px; border-left:1px solid #f3f3f3; background:#fbfbfb; }
/* Two-column workspace: clean pure white on white. */
.application-body .recipe-view-workspace { grid-template-columns:1fr 1fr; border-top:0; background:#ffffff; }
.application-body .recipe-view-formula { padding:26px 36px 35px; border-right:0; background:#ffffff; }
.application-body .recipe-view-details { padding:26px 32px 60px; border-left:1px solid #edf0f5; background:#ffffff; }
/* Formula heading: label + value text controls, no input boxes. */
.application-body .recipe-view-formula .calculator-heading { display:flex; align-items:center; gap:36px; margin-bottom:26px; }
@@ -2274,7 +2280,26 @@ input[type="search"]::-webkit-search-results-decoration,
.application-body .recipe-view-formula .calculator-heading .input-with-unit > span { border-left:0; color:#050841; font-size:14px; font-weight:400; }
/* Formula table: borderless rows, meez typography. */
.application-body .recipe-view-formula .formula-component { margin:0 0 16px 0; border:0; background:transparent; font:inherit; }
.application-body .recipe-view-formula .formula-component { margin:0 0 20px 0; border:0; background:transparent; font:inherit; }
.application-body .recipe-view-formula .formula-component h3,
.application-body .recipe-view-formula .formula-component-heading {
margin: 18px 0 8px 0;
padding: 0 0 6px 0;
color: #050841;
font-family: var(--meez-font-sans);
font-size: 18px;
font-weight: 700;
line-height: 24px;
border-bottom: 1px solid #edf0f5;
letter-spacing: 0;
}
.application-body .recipe-view-formula .formula-component-note {
margin: 4px 0 8px;
color: #757677;
font-size: 13px;
font-style: italic;
line-height: 1.4;
}
.application-body .recipe-view-formula .recipe-ingredients thead { display:none; }
.application-body .recipe-view-formula .recipe-ingredients td { padding:8px 0; border:0; border-bottom:0; background:transparent; }
.application-body .recipe-view-formula .recipe-ingredients td:first-child { padding-right:10px; }
@@ -2288,12 +2313,38 @@ input[type="search"]::-webkit-search-results-decoration,
/* Prep method panel. */
.application-body .recipe-view-method h2 { margin:0 0 34px; font-size:22px; font-weight:700; line-height:33px; }
.application-body .recipe-view-method h2 small { margin-left:8px; color:#a5a9c1; font-size:16px; font-weight:700; }
.application-body .recipe-view-method ol { padding:0; list-style:none; }
.application-body .recipe-view-method li { grid-template-columns:28px minmax(0,1fr); gap:16px; min-height:0; padding:16px 0; background:transparent; border:0; border-bottom:1px solid #f3f3f3; }
.application-body .recipe-view-method ol { padding:0; list-style:none; margin:0; }
.application-body .recipe-view-method li { grid-template-columns:28px minmax(0,1fr); gap:16px; min-height:0; padding:16px 0; background:transparent; border:0; border-bottom:1px solid #edf0f5; }
.application-body .recipe-view-method li:last-child { border-bottom:0; }
.application-body .recipe-view-method li > strong { color:#050841; font-size:16px; font-weight:700; }
.application-body .recipe-view-method li > span { color:#050841; font-size:16px; font-weight:300; line-height:20px; }
.application-body .recipe-view-method li.placeholder > span { color:#a5a9c1; }
.application-body .recipe-view-method li.prep-heading {
display: block;
padding: 20px 0 6px;
border-bottom: 1px solid #edf0f5;
}
.application-body .recipe-view-method li.prep-heading h3,
.application-body .recipe-view-method .method-heading-text {
margin: 0;
color: #050841;
font-family: var(--meez-font-sans);
font-size: 18px;
font-weight: 700;
line-height: 24px;
}
.application-body .recipe-view-method li.prep-note {
display: block;
padding: 8px 0;
}
.application-body .recipe-view-method li.prep-note p,
.application-body .recipe-view-method .method-note-text {
margin: 0;
color: #757677;
font-size: 14px;
font-style: italic;
line-height: 1.4;
}
/* Secondary panels: meez panel headings and copy. */
/* U of M Equivalency: Meez Style */
.application-body .recipe-view-equivalencies {
@@ -2574,7 +2625,7 @@ input[type="search"]::-webkit-search-results-decoration,
}
/* Recipe read view — meez steps clone (measured live 2026-08-15). */
.application-body .immersive-main:has(.recipe-read-shell) { background:#fbfbfb; }
.application-body .immersive-main:has(.recipe-read-shell) { background:#ffffff; }
.application-body .immersive-main .recipe-read-shell {
display:grid;
grid-template-columns:minmax(0,1fr) minmax(0,1fr);
@@ -2586,7 +2637,7 @@ input[type="search"]::-webkit-search-results-decoration,
.application-body .recipe-read-shell .recipe-view-formula-pane,
.application-body .recipe-read-shell .recipe-view-details-pane { flex:none; min-width:0; }
/* App bar: 48px #fbfbfb, no bottom border. */
/* App bar: 48px #ffffff, no bottom border. */
.application-body .recipe-read-shell .detail-utility {
position:sticky;
top:0;
@@ -2594,7 +2645,7 @@ input[type="search"]::-webkit-search-results-decoration,
height:48px;
min-height:48px;
padding:0;
background:#fbfbfb;
background:#ffffff;
border-bottom:0;
}
.application-body .recipe-read-shell .detail-context,
@@ -2674,8 +2725,8 @@ input[type="search"]::-webkit-search-results-decoration,
min-height:48px;
margin:0;
padding:0;
background:#fbfbfb;
border-bottom:2px solid #f3f3f3;
background:#ffffff;
border-bottom:2px solid #edf0f5;
}
.application-body .immersive-main .recipe-read-shell .recipe-workspace-tabs button,
.application-body .recipe-read-shell .recipe-workspace-tabs button,
@@ -2814,8 +2865,8 @@ input[type="search"]::-webkit-search-results-decoration,
.application-body .immersive-main .recipe-read-shell .recipe-view-details {
padding:0 32px 32px;
border-top:0;
border-left:1px solid #f3f3f3;
background:#fbfbfb;
border-left:1px solid #edf0f5;
background:#ffffff;
}
/* Tab panel switching: clean active state with no layout reflow or margin leakage */
.application-body .recipe-view-details > .recipe-tab-panel,
@@ -3135,7 +3186,7 @@ input[type="search"]::-webkit-search-results-decoration,
min-height: auto;
margin: 0;
padding: 0;
background: #fbfbfb;
background: #ffffff;
}
.application-body .ingredient-read-left {
@@ -3470,7 +3521,7 @@ input[type="search"]::-webkit-search-results-decoration,
align-items: stretch;
width: 100%;
max-width: 100%;
background: #fbfbfb;
background: #ffffff;
box-sizing: border-box;
}
@@ -3775,9 +3826,9 @@ input[type="search"]::-webkit-search-results-decoration,
height: 48px !important;
margin: 0 !important;
padding: 0 !important;
background: #fbfbfb !important;
background: #ffffff !important;
border: 0 !important;
border-bottom: 1px solid #f3f3f3 !important;
border-bottom: 1px solid #edf0f5 !important;
box-sizing: border-box !important;
}
+4 -4
View File
@@ -4,10 +4,10 @@
*/
:root {
/* Surface Colors */
--meez-bg-page: #f3f3f3;
--meez-bg-card: #fbfbfb;
--meez-bg-card-alt: #f1f5fe;
--meez-bg-card-hover: #f1f5fe;
--meez-bg-page: #ffffff;
--meez-bg-card: #ffffff;
--meez-bg-card-alt: #fafbfd;
--meez-bg-card-hover: #f8faff;
--meez-bg-white: #ffffff;
--meez-bg-active-pill: #dbe4ff;