Compare commits
7
Commits
master
...
685cf86819
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
685cf86819 | ||
|
|
d378842b68 | ||
|
|
21651e1ce8 | ||
|
|
272642f372 | ||
|
|
f0e82ff19f | ||
|
|
551bdc4104 | ||
|
|
deb2c15ab4 |
+3
-4
@@ -31,8 +31,7 @@ Thumbs.db
|
||||
/ui-reference*.html
|
||||
/ui-reference*_files/
|
||||
/ui-reference*.png
|
||||
/screenshots/
|
||||
|
||||
# Local application databases and SQLite sidecars
|
||||
/var/*.sqlite
|
||||
/var/*.sqlite-shm
|
||||
/var/*.sqlite-wal
|
||||
# Local application databases, backups, logs, and SQLite sidecars
|
||||
/var/
|
||||
|
||||
@@ -44,17 +44,33 @@ 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.
|
||||
|
||||
All normal recipe, ingredient, nutrition-mapping, and purchasing changes must
|
||||
be written to SQLite through the application or its validated database
|
||||
functions. This rule also applies to automated and AI-assisted edits. Do not
|
||||
edit `culinary/*.yaml` as a way to update a running application, and do not use
|
||||
unrestricted SQL when `saveRecipeStructure()` or another domain save function
|
||||
is available.
|
||||
|
||||
Create a new local database from the portable culinary dataset:
|
||||
|
||||
```bash
|
||||
npm run db:reset
|
||||
```
|
||||
|
||||
This command replaces an existing local database. There is intentionally no
|
||||
legacy upgrade chain. YAML under `culinary/` is retained as portable seed and
|
||||
interchange data; normal edits in the management application write to SQLite.
|
||||
**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.
|
||||
|
||||
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).
|
||||
|
||||
## Development
|
||||
|
||||
@@ -64,6 +80,12 @@ Run the editor:
|
||||
npm run dev:app
|
||||
```
|
||||
|
||||
Ingredient bulk entry uses the local Ollama service through
|
||||
`http://10.0.10.211:11434/api/chat` and the purpose-built
|
||||
`qwen3:4b-instruct` parsing prompt. Override these defaults with
|
||||
`FORMULATION_OLLAMA_URL` and `FORMULATION_INGREDIENT_PARSER_MODEL`. The parser
|
||||
endpoint is disabled whenever `FORMULATION_READ_ONLY=true`.
|
||||
|
||||
Open <http://localhost:4322/app/>.
|
||||
|
||||
To stop any process listening on the application port and start a fresh Astro
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Agent handoff
|
||||
|
||||
## Repository state
|
||||
|
||||
Development happens on `dev`; `master` is the deployable integration branch.
|
||||
Use Node.js 22 or newer and install dependencies with `npm ci`.
|
||||
|
||||
SQLite is the canonical writable store. YAML in `culinary/` is portable seed and
|
||||
interchange data, not the live editing surface. Application and automated edits
|
||||
should use validated domain functions and transactions rather than unrestricted
|
||||
SQL or direct YAML changes.
|
||||
|
||||
## Start from the committed seed data
|
||||
|
||||
```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.
|
||||
|
||||
## Transfer the latest application data
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
npm run db:backup -- /safe/transfer/recipe-book.sqlite
|
||||
```
|
||||
|
||||
Place the transferred file at `var/recipe-book.sqlite` on the receiving machine.
|
||||
The backup command uses SQLite's online backup API, includes committed WAL data,
|
||||
and refuses to overwrite an existing destination.
|
||||
|
||||
## Validate a change
|
||||
|
||||
```sh
|
||||
scripts/validate-content
|
||||
npm run check:app
|
||||
npm test
|
||||
npm run build:app
|
||||
git diff --check
|
||||
```
|
||||
|
||||
The application supports a read-only deployment with
|
||||
`FORMULATION_READ_ONLY=true`. Ingredient bulk parsing additionally accepts
|
||||
`FORMULATION_OLLAMA_URL` and `FORMULATION_INGREDIENT_PARSER_MODEL`; USDA imports
|
||||
read `USDA_FDC_API_KEY` from the environment.
|
||||
@@ -13,10 +13,11 @@ current canonical state. Names used for search or display belong in ingredient
|
||||
|
||||
## Canonical and derived boundaries
|
||||
|
||||
Canonical records live under `culinary/`. They contain authored or observed
|
||||
Canonical writable records live in SQLite. They include authored or observed
|
||||
facts: recipes, ingredients, measurements, provenance, suppliers, packages,
|
||||
and price observations. Projections may later be generated for another
|
||||
application or database, but they are never canonical.
|
||||
and price observations. YAML under `culinary/` is portable seed/interchange
|
||||
data, while generated site projections and exports are downstream products.
|
||||
Neither is an independently writable source of truth.
|
||||
|
||||
Derived recipe records contain reproducible nutrition, allergen rollups, and
|
||||
costs. They identify the recipe, calculation version, calculation time, and an
|
||||
@@ -129,7 +130,14 @@ truth. Recipes without authored instructions contain one explicit TODO step.
|
||||
Formula-only conversions use a nominal 100 g basis and a
|
||||
theoretical yield until those values are replaced by observed production data.
|
||||
|
||||
Astro reads a generated, read-only SQLite projection and produces static recipe
|
||||
pages. Interactive calculators are small Preact islands supplied with resolved,
|
||||
typed recipe data. Astro and Preact remain presentation consumers; culinary
|
||||
calculations and editing originate in the database and shared calculation tools.
|
||||
Application and agent changes must use validated domain save functions and
|
||||
SQLite transactions. Direct SQL is reserved for schema-aware maintenance where
|
||||
no domain operation exists. Reset/import commands flow from YAML into SQLite and
|
||||
therefore overwrite the current store; export commands flow from SQLite into a
|
||||
portable representation.
|
||||
|
||||
Astro reads SQLite through the application data layer. Generated projections
|
||||
support read-only presentation, and interactive calculators are Preact islands
|
||||
supplied with resolved, typed recipe data. Astro, Preact, and projections remain
|
||||
presentation consumers; culinary calculations and editing originate in SQLite
|
||||
and shared domain tools.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Formulation Documentation & Knowledge Base
|
||||
|
||||
Welcome to the Formulation Help Center. This documentation explains the architecture, business logic, and operational workflows for managing recipes, ingredients, costs, units of measure, inventory, and archives.
|
||||
|
||||
---
|
||||
|
||||
## Knowledge Base Directory
|
||||
|
||||
### 🚀 Getting Started
|
||||
- [Workspace Navigation & Global Search](./getting-started/workspace-navigation.md): Navigating workspaces, filtering catalogs, global search, and keyboard shortcuts.
|
||||
|
||||
### 🍳 Recipes & Formulas
|
||||
- [Scaling, Batching & Yield Calculations](./recipes/scaling-and-yields.md): Interactive scaling, yield conversions, weight-based auto-yields, and portion control.
|
||||
- [Baker's & Standard Percentages](./recipes/bakers-percentages.md): Flour basis calculation, dynamic target weights, and formula ratios.
|
||||
- [Sub-recipes & Prep Methods](./recipes/sub-recipes-and-prep.md): Nesting recipes as ingredients, prep instructions, headers, notes, and equipment tracking.
|
||||
|
||||
### 🌿 Ingredients & Units of Measure
|
||||
- [Units of Measure & Custom Equivalencies](./ingredients/units-and-equivalencies.md): Dimensional systems (mass, volume, count), canonical conversions, density measures, and USDA nutrition mapping.
|
||||
|
||||
### 💰 Costing & Purchasing
|
||||
- [Purchase Items, Pack Sizes & Recipe Costing](./costing/purchase-items-and-costing.md): Invoices, pack configurations, yield factors, price history, food cost per batch, and cost per serving.
|
||||
|
||||
### 📦 Inventory Management
|
||||
- [Inventory Steps for Success (5-Week Implementation Roadmap)](./inventory/inventory-calendar-steps-for-success.md): Full operational guide for going from initial setup to first live period-end inventory count.
|
||||
- [Count Sheets & Storage Locations](./inventory/count-sheets-and-locations.md): Location-specific sheet-to-shelf counting, on-hand inputs, and live extended valuations.
|
||||
|
||||
### 🗄️ Archive & Trash Lifecycle
|
||||
- [Archive & Lifecycle Management](./archive/lifecycle-and-restoration.md): Soft-deletion, catalog filtering, multi-item batch restore, and safe permanent deletion guards.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Archive & Lifecycle Management
|
||||
|
||||
Formulation implements a two-stage deletion lifecycle (Soft Delete $\rightarrow$ Hard Delete) with automated dependency safeguards to protect culinary data integrity.
|
||||
|
||||
---
|
||||
|
||||
## 1. Soft Deletion (Archiving)
|
||||
|
||||
- When an ingredient, recipe, or recipe book is deleted, it is **soft-deleted** (`deleted_at` timestamp recorded) rather than purged immediately.
|
||||
- Archived items are immediately hidden from active searches, auto-complete dropdowns, and directory views.
|
||||
- Active recipes that historically reference an archived ingredient remain intact without breaking calculations.
|
||||
|
||||
---
|
||||
|
||||
## 2. Archive Workspace (`/app/archive/`)
|
||||
|
||||
The Archive workspace allows viewing and managing all removed items:
|
||||
- **Filter by Entity**: Filter by *All*, *Recipes*, *Ingredients*, or *Recipe Books*.
|
||||
- **Multi-Item Batch Selection**: Select multiple items using checkboxes to perform bulk actions.
|
||||
- **Batch Restore**: Instantly restore selected items back to the active catalog.
|
||||
- **Safe Permanent Deletion**:
|
||||
- Permanently purges items from the database.
|
||||
- **Dependency Safeguard**: Formulation automatically verifies whether an item is still referenced by any active recipe or sub-recipe. If dependencies exist, hard deletion is blocked with a clear warning explaining where the item is currently used.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Purchase Items, Pack Sizes & Recipe Costing
|
||||
|
||||
Accurate recipe food costing relies on mapping real-world vendor purchase packages to canonical ingredients.
|
||||
|
||||
---
|
||||
|
||||
## 1. Purchase Items & Pack Configurations
|
||||
|
||||
A **Purchase Item** represents a commercial package purchased from a vendor or distributor:
|
||||
- **Pack Size & Unit**: e.g., `50 lb Bag`, `6 x 1 Gallon Case`, `16 oz Container`.
|
||||
- **Cost**: Total package purchase price (e.g. `$24.50`).
|
||||
- **Yield Factor (%)**: The usable portion percentage after trimming or prep (e.g. 85% usable yield on trimmed beef tenderloin, 100% on flour).
|
||||
- **Unit Cost**: Automatically computed per base unit (e.g. `$0.00108 / gram` or `$0.49 / lb`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Recipe Food Costing Breakdown
|
||||
|
||||
When viewing a recipe's **Cost** tab:
|
||||
1. **Line Cost**: Each ingredient's line item cost is calculated as:
|
||||
$$\text{Line Cost} = \frac{\text{Quantity} \times \text{Unit Cost}}{\text{Yield Factor}}$$
|
||||
2. **Total Batch Cost**: The sum of all line item costs for the batch.
|
||||
3. **Cost per Serving**: Total Batch Cost divided by total yield servings.
|
||||
4. **Food Cost % (Target Selling Price)**:
|
||||
$$\text{Suggested Price} = \frac{\text{Cost per Serving}}{\text{Target Food Cost \%}}$$
|
||||
@@ -0,0 +1,36 @@
|
||||
# Workspace Navigation & Global Search
|
||||
|
||||
Formulation provides a streamlined, fast, centralized directory for managing all culinary data across your operation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Directory Workspace & Workspace Pills
|
||||
|
||||
The home directory (`/app/`) categorizes items into distinct workspaces using top pill badges:
|
||||
|
||||
- **Recipes** (Blue badge): Standalone formulas, prep recipes, and batch formulations.
|
||||
- **Ingredients** (Green badge): Raw culinary ingredients, allergens, density conversions, and supplier links.
|
||||
- **Recipe Books** (Purple badge): Curated collections and menus of recipes (e.g. *Dinner Menu*, *Cocktails*, *Bakery Line*).
|
||||
- **Purchase Items** (Cyan badge): Commercial vendor packages, invoice pack sizes, prices, and vendor SKUs.
|
||||
- **Inventory** (Teal badge): Active and past inventory count sessions with on-hand valuations.
|
||||
- **Archive** (Neutral link): Soft-deleted items ready for restoration or permanent purge.
|
||||
|
||||
---
|
||||
|
||||
## 2. Global Search & Autocompletion
|
||||
|
||||
- **Omnibox Search**: Search across recipe titles, ingredient names, aliases, and purchase items simultaneously.
|
||||
- **Type Filtering**: Narrow search results by specific entity type directly from the search dropdown filter.
|
||||
- **Keyboard Navigation**:
|
||||
- <kbd>Tab</kbd> / <kbd>Arrow Down</kbd>: Highlight matching search candidates.
|
||||
- <kbd>Enter</kbd>: Open the selected recipe or ingredient detail card immediately.
|
||||
- <kbd>Escape</kbd>: Clear search and close active popups.
|
||||
|
||||
---
|
||||
|
||||
## 3. Detail Utility Bar
|
||||
|
||||
Every single item detail page features a fixed top utility bar containing:
|
||||
- **Workspace Breadcrumbs**: Direct navigation back to the active directory workspace.
|
||||
- **Global Search**: Search and jump to other items without returning to the home screen.
|
||||
- **New Action Button (`+ New`)**: Quick creation modal for recipes, ingredients, recipe books, or count sessions from anywhere in the app.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Units of Measure & Custom Equivalencies
|
||||
|
||||
Formulation maintains a rigorous, multi-dimensional unit conversion engine that enforces physical dimensional rules while supporting culinary volume-to-weight equivalencies.
|
||||
|
||||
---
|
||||
|
||||
## 1. Dimensional Systems
|
||||
|
||||
Every unit belongs to a fundamental physical dimension:
|
||||
- **Mass** (Base unit: `gram`): `gram`, `kilogram`, `pound`, `ounce_mass`.
|
||||
- **Volume** (Base unit: `milliliter`): `milliliter`, `liter`, `cup_us` (240 mL legal), `tablespoon_us`, `teaspoon_us`, `fluid_ounce_us`.
|
||||
- **Count** (Base unit: `each`): `each`, `clove`, `head`, `bunch`.
|
||||
- **Temperature** (Affine scale): `fahrenheit`, `celsius`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Density & Ingredient-Specific UoM Equivalencies
|
||||
|
||||
Because ingredients possess different bulk densities (e.g. 1 cup of all-purpose flour = ~120g, whereas 1 cup of honey = ~340g), volume-to-mass conversions require density records.
|
||||
|
||||
### A. UoM Equivalencies Panel
|
||||
- On each ingredient page, the **UoM Equivalency** tab allows defining custom portion measurements:
|
||||
- *Example*: `1 cup = 125 g`
|
||||
- *Example*: `1 medium apple = 182 g`
|
||||
- *Example*: `1 clove garlic = 3 g`
|
||||
|
||||
### B. Resolution Precedence
|
||||
1. **Reviewed Portions / Measures**: Checked first for an exact unit match (e.g. `cup` or `each`).
|
||||
2. **Bulk Density Measurements**: Checked if converting between standard volume and mass dimensions.
|
||||
3. **Canonical Unit Factor**: Applied for within-dimension conversions (e.g. `lb` to `oz`).
|
||||
@@ -0,0 +1,24 @@
|
||||
# Count Sheets & Storage Locations
|
||||
|
||||
Inventory in Formulation is built for high-speed, sheet-to-shelf counting across physical storage locations.
|
||||
|
||||
---
|
||||
|
||||
## 1. Storage Locations
|
||||
|
||||
Organize your kitchen into logical physical zones:
|
||||
- **Walk-in Cooler**: Dairy, produce, raw proteins, prepped batch items.
|
||||
- **Dry Storage**: Flours, grains, spices, oils, canned goods.
|
||||
- **Freezer**: Frozen stocks, puff pastry, frozen proteins.
|
||||
- **Bar / Front of House**: Spirits, syrups, mixers, garnishes.
|
||||
- **Line Stations**: Sauté station drawers, prep table bins.
|
||||
|
||||
---
|
||||
|
||||
## 2. Conducting an Inventory Count
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Inventory Steps for Success
|
||||
|
||||
A structured 5-week roadmap to build out recipes, configure purchasing units and costs, organize location-specific count sheets, test inventory counting, and successfully conduct your first live inventory.
|
||||
|
||||
---
|
||||
|
||||
## 5-Week Roadmap Overview
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title Inventory Onboarding & Setup Roadmap
|
||||
dateFormat X
|
||||
axisFormat Day %d
|
||||
section Week 1
|
||||
Recipes & Ingredients Setup :active, 1, 7
|
||||
section Week 2
|
||||
Purchasing Units & Costs :2, 14
|
||||
section Week 3
|
||||
Location Count Sheets :3, 21
|
||||
section Week 4
|
||||
Dry-Run Test Counts :4, 28
|
||||
section Week 5
|
||||
First Live Inventory & Analytics :5, 35
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Week 1: Build Out Recipes & Ingredients Tables
|
||||
|
||||
> **Week 1 Goal**: Your complete recipe database and canonical ingredient list are populated and ready for kitchen use.
|
||||
|
||||
| Day | Action Item | Details & Instructions |
|
||||
|---|---|---|
|
||||
| **Monday** | **Goal Kickoff** | Define the scope of prep items, sub-recipes, and raw ingredients to be tracked. |
|
||||
| **Tuesday** | **Start with Prep Recipes** | Begin by entering your prep recipes and sub-recipes. As you add prep recipes, your canonical ingredient list will automatically populate. |
|
||||
| **Wednesday** | **Audit & Merge Ingredients** | Review your ingredient catalog. Identify duplicates or near-duplicates (e.g. "kosher salt" vs "salt kosher") and merge them into single canonical ingredients. |
|
||||
| **Thursday** | **Duplicate Multi-Type Items** | Make distinct copies of ingredients where you use multiple varieties or grades of the same item (e.g. *Flour - All Purpose* vs *Flour - Bread High Gluten*). |
|
||||
| **Friday** | **Review Kitchen Database** | Verify that recipes have components, steps, and yields properly structured. |
|
||||
| **Saturday & Sunday** | **Milestone Check** | **Look at that!** You now have a complete, standardized recipe database that can be used actively on the kitchen line. |
|
||||
|
||||
---
|
||||
|
||||
## Week 2: Configure Costs & Purchase Units
|
||||
|
||||
> **Week 2 Goal**: All inventoried ingredients have verified purchase packages, unit costs, and yield factors.
|
||||
|
||||
| Day | Action Item | Details & Instructions |
|
||||
|---|---|---|
|
||||
| **Monday** | **Goal Kickoff** | Gather recent supplier invoices, receipts (e.g., Walmart, Sam's Club, US Foods, Sysco), and vendor order guides. |
|
||||
| **Tuesday** | **Invoice Processing & Linking** | Ingest invoice lines into the system to extract package sizes, prices, and vendor SKU codes. |
|
||||
| **Wednesday** | **Manual Costing** | For specialty or local market items without digital invoices, enter package costs manually on the ingredient cost panel. |
|
||||
| **Thursday** | **Spreadsheet Import** | If you maintain vendor price lists in spreadsheets, upload or batch-map purchase packages into your catalog. |
|
||||
| **Friday** | **Map New Purchase Items** | Use the Purchase Items table to map raw invoice line descriptions to their canonical formulation ingredients. |
|
||||
| **Saturday** | **Audit Missing Costs** | Filter your ingredient directory to inspect which items are still unpriced. Add missing package sizes. |
|
||||
| **Sunday** | **Milestone Check** | **Prep recipes now show real costs!** Take a well-deserved break—your recipe costing foundation is complete. |
|
||||
|
||||
---
|
||||
|
||||
## Week 3: Build Location Count Sheets
|
||||
|
||||
> **Week 3 Goal**: Sheet-to-shelf inventory count lists are configured for each physical storage area.
|
||||
|
||||
| Day | Action Item | Details & Instructions |
|
||||
|---|---|---|
|
||||
| **Monday** | **Goal Kickoff** | Identify all physical storage areas across your operation (e.g., *Walk-In Cooler*, *Dry Storage*, *Freezer*, *Bar*, *Line Drawers*). |
|
||||
| **Tuesday** | **Create Count Sheets** | Go to **+ New** and select **Count Sheet**. Ensure count sheets are strictly location-specific. *(Note: Only managers can create count templates).* |
|
||||
| **Wednesday** | **Order "Sheet to Shelf"** | Arrange ingredients in the exact physical order they appear on your shelves (top-to-bottom, left-to-right). This maximizes counting speed and prevents missed items. |
|
||||
| **Thursday** | **Add Ingredients & Batches** | Add raw ingredients (green icon) and prepped batch recipes (blue icon) to each count sheet. Drag and drop to reorder. |
|
||||
| **Friday** | **Set Count Units** | Verify and adjust count units (e.g. *Cases*, *Bags*, *Each*, *Pounds*) to match how cooks physically count each shelf. Count units default to the ingredient's primary purchase unit. |
|
||||
| **Saturday & Sunday** | **Milestone Check** | **Almost there!** All location count sheets are structured and ready for validation. |
|
||||
|
||||
---
|
||||
|
||||
## Week 4: Test Run & Validate Inventory Lists
|
||||
|
||||
> **Week 4 Goal**: Perform a dry-run test count to uncover unit mismatch errors, pack size discrepancies, or missing items.
|
||||
|
||||
| Day | Action Item | Details & Instructions |
|
||||
|---|---|---|
|
||||
| **Monday** | **Goal Kickoff** | Schedule a 20-minute test run with key kitchen leads before service. |
|
||||
| **Tuesday** | **Enter Test Count Values** | Enter a dummy quantity of **`1`** in every column (or enter last month's closing count). Save and submit each location sheet individually (do not submit total final count). |
|
||||
| **Wednesday** | **Export Valuation Report** | Review the calculated on-hand values and line-item totals in the analytics review. |
|
||||
| **Thursday** | **Identify Discrepancies** | Look for extended dollar values that look unusually high or low. This highlights where pack sizes (e.g. $50/case counted as 1 ea = $50 vs $2.08) or count units need calibration. |
|
||||
| **Friday** | **Correct Count Templates** | Update pack sizes, count units, or ingredient equivalencies based on test run findings. |
|
||||
| **Saturday & Sunday** | **Milestone Check** | **Take a deep breath!** Your inventory templates are calibrated, validated, and ready for real operational use. |
|
||||
|
||||
---
|
||||
|
||||
## Week 5: Conduct Your First Live Inventory
|
||||
|
||||
> **Week 5 Goal**: Successfully execute full period-end inventory, capture total valuation, and establish your inventory baseline.
|
||||
|
||||
| Day | Action Item | Details & Instructions |
|
||||
|---|---|---|
|
||||
| **Monday** | **Conduct Live Count** | Assign team members to their respective locations with mobile devices or clipboards. |
|
||||
| **Tuesday** | **Add Items On the Fly** | If an unlisted item is discovered on a shelf during the count, add it on the fly. *(Remember to add it to the master count template afterward).* |
|
||||
| **Wednesday** | **Review & Submit Count** | Once all location lists are filled, managers review pending location totals and submit the total inventory count. |
|
||||
| **Thursday** | **Analyze Inventory Valuation** | Review the total dollar valuation report by storage location and ingredient category. *(Calculations finalize within minutes).* |
|
||||
| **Friday** | **Export Accounting Reports** | Export your finalized inventory valuation breakdown categorized by GL accounting codes for bookkeeping. |
|
||||
| **Saturday & Sunday** | **Celebrate Success!** | You now have a repeatable, accurate, high-speed inventory process embedded into your culinary operations! |
|
||||
@@ -0,0 +1,26 @@
|
||||
# Baker's & Standard Percentages
|
||||
|
||||
In baking and commercial food manufacturing, formulas are often structured using **percentages** to ensure recipe scalability and hydration control.
|
||||
|
||||
---
|
||||
|
||||
## 1. Standard Percentage vs. Baker's Percentage
|
||||
|
||||
### Standard % (Total Formulation Basis)
|
||||
$$\text{Standard \%} = \frac{\text{Ingredient Weight}}{\text{Total Batch Weight}} \times 100$$
|
||||
- In Standard Percentage mode, the sum of all ingredient percentages in the recipe equals **100%**.
|
||||
- Useful 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).
|
||||
|
||||
---
|
||||
|
||||
## 2. Dynamic 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!
|
||||
@@ -0,0 +1,34 @@
|
||||
# Scaling, Batching & Yield Calculations
|
||||
|
||||
Formulation is a weight-first formulation engine designed to scale recipes accurately across commercial batch sizes without calculation rounding drift.
|
||||
|
||||
---
|
||||
|
||||
## 1. Batch Multipliers vs. Yield-Target Scaling
|
||||
|
||||
Recipes can be scaled in two primary modes:
|
||||
|
||||
### A. Batch Multiplier (`x` factor)
|
||||
- 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.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## 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."*
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Sub-recipes & Prep Methods
|
||||
|
||||
Recipes in Formulation can seamlessly nest other recipes as **sub-recipes**, enabling modular batch preparation and accurate cost/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*).
|
||||
|
||||
---
|
||||
|
||||
## 2. Structured Prep Method
|
||||
|
||||
The **Prep Method** editor allows organizing kitchen instructions into numbered, ordered 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.
|
||||
@@ -4,6 +4,27 @@ The local application uses SQLite as its canonical data store. YAML remains a
|
||||
portable import/export format, but normal application saves do not modify it.
|
||||
Derived nutrition and cost are still calculated rather than stored.
|
||||
|
||||
## Source-of-truth rule
|
||||
|
||||
- SQLite is the only writable source of truth for a running installation.
|
||||
- Humans should edit through the management application.
|
||||
- Automation and AI agents should call validated application commands or domain
|
||||
save functions such as `saveRecipeStructure()`.
|
||||
- Agents should not edit YAML to change live data and should not issue
|
||||
unrestricted SQL when a domain operation exists.
|
||||
- Generated site projections and exports are downstream products of SQLite.
|
||||
|
||||
A safe automated recipe change follows this flow:
|
||||
|
||||
```text
|
||||
agent request
|
||||
-> validate recipe structure and references
|
||||
-> domain save function
|
||||
-> SQLite transaction
|
||||
-> refresh derived projection
|
||||
-> optional explicit export for backup or review
|
||||
```
|
||||
|
||||
Create the initial database from the current portable dataset with Node 22 or
|
||||
newer, then run either application mode:
|
||||
|
||||
@@ -24,5 +45,32 @@ 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.
|
||||
|
||||
`npm run db:import:yaml` and `npm run db:reset` both replace the database from
|
||||
portable data and are intended for initial setup or an explicit restore.
|
||||
`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.
|
||||
|
||||
## Windows dev-server notes
|
||||
|
||||
Node is installed at `C:\Program Files\nodejs` but is not on the default
|
||||
agent shell PATH. Prefix every npm/npx command:
|
||||
|
||||
```bat
|
||||
cmd /c "set PATH=C:\Program Files\nodejs;%PATH%&& npm run dev:app"
|
||||
```
|
||||
|
||||
`astro dev` runs as a detached daemon (Astro 7). To stop it, find the PID
|
||||
from the port and kill it directly — `scripts/restart-app.mjs` reads `/proc`
|
||||
and does not work on Windows:
|
||||
|
||||
```bat
|
||||
netstat -ano | findstr :4322
|
||||
taskkill /PID <pid> /F /T
|
||||
```
|
||||
|
||||
A long-running dev server inherited from another session can degrade
|
||||
silently: pages render but Preact islands never hydrate (empty
|
||||
`astro-island`, no console error). Before debugging component code, check
|
||||
whether the recipe table hydrates and, if not, restart the dev server. The
|
||||
URL pattern `?astro&type=script` returns 500 even when hydration works — it
|
||||
is not a valid diagnostic.
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# meez home page measurements
|
||||
|
||||
Live measurements of the meez recipe home page
|
||||
(`https://app.getmeez.com/home?type=recipe`) used as the styling target for
|
||||
the Formulation app home page. Measured 2026-08-14 with an authenticated
|
||||
relay-browser tab at a 1440×900 viewport, via `getComputedStyle` and
|
||||
`getBoundingClientRect`. Re-measure before trusting these values on a new
|
||||
design pass.
|
||||
|
||||
## Global
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| body background | `#f3f3f3` |
|
||||
| font family | `CircularCustCapNum, sans-serif` (proprietary; Formulation uses the Inter fallback stack) |
|
||||
| base font | 16px / weight 300 / line-height 20px |
|
||||
| text color | `#050841` |
|
||||
|
||||
## Header (sticky top bar)
|
||||
|
||||
- Height 80px, background `#fbfbfb`, no border, no shadow, z-index 1100.
|
||||
- Inner toolbar: padding `0 24px`, min-height 48px.
|
||||
|
||||
### Search box
|
||||
|
||||
- Container: 504 × 40px, background `#fff`, border 1px solid `#f3f3f3`,
|
||||
border-radius 4px, padding `0 12px`.
|
||||
- Icon: 18px, `#8283a0`.
|
||||
- Input: 16px / weight 400, line-height 23px, padding `8px 0 6px`,
|
||||
color `rgba(0,0,0,.87)`; placeholder `#a5a9c1`.
|
||||
|
||||
### New button
|
||||
|
||||
- Height 32px, border-radius 100px, background `#3d5df6`, white text,
|
||||
16px / weight 500, padding `0 12px`.
|
||||
- "add" icon 18px, 8px gap to label.
|
||||
|
||||
### New dropdown
|
||||
|
||||
- Paper: 375px wide, border-radius 4px, no border, MUI elevation-2 shadow:
|
||||
`0 2px 1px -1px rgba(0,0,0,.2), 0 1px 1px 0 rgba(0,0,0,.14), 0 1px 3px 0 rgba(0,0,0,.12)`.
|
||||
- List padding `22px 0`; items min-height 43.4px, padding `8px 25px`.
|
||||
- Item icon: 24px, `#a5a9c1`. Label: 16px / weight 500, `#050841`.
|
||||
- Hover background `#f1f5fe`.
|
||||
|
||||
## Workspace layout
|
||||
|
||||
- Content column 1120px wide, centered; page wrapper padding `0 32px 32px`.
|
||||
- Tabs row starts ≈26px below the header; table header ≈46px below the tabs row.
|
||||
|
||||
### Tab chips (workspace pills)
|
||||
|
||||
- Chip: 40px tall, border-radius 99px, padding `8px 16px 8px 12px`,
|
||||
gap 8px, 16px / weight 400, color `rgba(0,0,0,.87)`.
|
||||
- Inactive: background `#fff`, border 1px solid `#ececec`.
|
||||
- Active: background `#DBE4FF`, no border.
|
||||
- No hover change (hover state identical to resting state).
|
||||
- Icon: 20px circle, white SVG with 1.5px inner padding. Per type:
|
||||
- recipe `#3C4679`
|
||||
- ingredient `#3F908A`
|
||||
- book `#F3A642`
|
||||
- purchase `#3F908A`
|
||||
- Label: 16px / weight 500, `#050841`, 8px right margin.
|
||||
- Count: 13px / weight 500, `#a5a9c1`.
|
||||
|
||||
### Filter chip
|
||||
|
||||
- Transparent background, border-radius 20px, padding 10px.
|
||||
- Funnel SVG 15×18 (viewBox `0 0 18 21`), `#050841`.
|
||||
- Text 15px / weight 400, `#050841`. Border stays transparent on hover.
|
||||
|
||||
## Directory table
|
||||
|
||||
- Header row: 56.66px, background `#fbfbfb`, border-bottom 1px solid
|
||||
`#f3f3f3`, grid gap 8px, no padding; checkbox cell 36px with 8px left padding.
|
||||
- Column titles: 14px / weight 400, `#a5a9c1`, line-height 21px.
|
||||
- Sort icon: 18px, `#a5a9c1`.
|
||||
- Data row: 56.66px, background `#fff`, border-bottom 1px solid `#f3f3f3`,
|
||||
gap 8px, no padding. **No hover background.**
|
||||
- Selected row background `#f1f5fe`.
|
||||
- meez columns: 36 (checkbox) | 112 (type) | 560 (name) | 112 (owner) |
|
||||
224 (last viewed) | 36 (actions). Formulation clones only checkbox, type,
|
||||
name, and actions — Owner/Last Viewed are explicitly out of scope.
|
||||
- Name text: 15px / weight 500, `#050841`, line-height 22.5px. The wrapping
|
||||
link is `#3d5df6` but the name paragraph overrides it.
|
||||
- Row type icon: 24px circle, white 20px SVG, same per-type colors as tabs.
|
||||
- Checkbox: 20×20; unchecked outline `#ececec`, checked fill `#3d5df6`
|
||||
with white check.
|
||||
- Row action button (more_vert): 24×24, `#a5a9c1`, border-radius 50%,
|
||||
hover background `rgba(0,0,0,.04)`.
|
||||
|
||||
### Row action menu
|
||||
|
||||
- Paper: 275px, border-radius 4px, elevation-2 shadow, list padding `8px 0`.
|
||||
- Items: min-height 47.4px, padding `10px 30px`, label 16px / weight 500
|
||||
`#050841`, icon 24px `#a5a9c1`.
|
||||
- Hover `#f1f5fe` (verified with real mouse input). Delete item `#f63d48`.
|
||||
|
||||
### Delete dialog
|
||||
|
||||
- Paper: 700px, border-radius 10px, padding `30px 38px`, MUI elevation-24
|
||||
shadow:
|
||||
`0 11px 15px -7px rgba(0,0,0,.2), 0 24px 38px 3px rgba(0,0,0,.14), 0 9px 46px 8px rgba(0,0,0,.12)`.
|
||||
- Backdrop `rgba(0,0,0,.5)`.
|
||||
- Title: 28px / weight 700, `#202962`, line-height 39px.
|
||||
- Subtitle: 15px / weight 400, `#050841`.
|
||||
- Cancel: transparent, 15px / weight 500, `#050841`, 52px gap to Delete.
|
||||
- Delete: `#f63d48` pill, 16px / weight 500, padding `16px 24px`,
|
||||
min-width 250px, height 48px, border-radius 100px.
|
||||
|
||||
## Dropdown panels (filter / item-type menus)
|
||||
|
||||
- 300px wide, border-radius 4px, no border, elevation-2 shadow,
|
||||
padding `14px 0`.
|
||||
- Labels: 14px; hover `#f1f5fe`. Apply button `#3d5df6`.
|
||||
|
||||
## Measurement cautions
|
||||
|
||||
- MUI hover states (menu items, chips) only appear with real mouse input
|
||||
(CDP mouse events), not synthetic `mouseover` dispatch.
|
||||
- The relay tab cannot screenshot while not visible; use computed styles
|
||||
and rects.
|
||||
- Re-measure on any new pass: these are point-in-time values from one
|
||||
authenticated account's render.
|
||||
|
||||
## Recipe read view (`/recipes/{id}/steps`)
|
||||
|
||||
Measured on the same account (recipe "Marinated Pork Belly"), same viewport
|
||||
rules apply. Formulation's counterpart: `/app/recipes/{id}/` (read mode).
|
||||
|
||||
### Header (48px, background `#fbfbfb`)
|
||||
|
||||
- Breadcrumb links "Home / Recipes": 12px / weight 400, `#202962`,
|
||||
line-height 22.5px; "/" separators `#95969c`, margin 0 4px.
|
||||
- Edit button: white pill, 1px `#3d5df6` border, radius 100px, height 32px,
|
||||
padding 0 12px, text 15px / weight 500 `#3d5df6`, edit icon 16px with 8px
|
||||
right margin.
|
||||
- Share button (not cloned): `#050841` pill, white 16px / weight 500.
|
||||
|
||||
### Layout
|
||||
|
||||
- Body `#f3f3f3`; content wrapper starts 16px below the header.
|
||||
- Two 50/50 columns, both background `#fbfbfb`; right column has a 1px
|
||||
`#f3f3f3` left border. Left padding `0 36px 35px`, right `0 32px 32px`.
|
||||
- meez keeps both columns at 1027px viewport width (stacking breakpoint is
|
||||
lower than Formulation's old 1280px).
|
||||
|
||||
### Left column (formula)
|
||||
|
||||
- Title: 28px / weight 900, `#050841`, line-height 42px; owner line
|
||||
12px / weight 500 directly below.
|
||||
- Batch/yield row: labels 14px / weight 400 `#050841` with 15px right
|
||||
padding; "1x" batch value 14px / weight 500 with `#a5a9c1` underline;
|
||||
yield value 15px / weight 500.
|
||||
- Ingredient table: no header row, no row borders. Component heading rows
|
||||
18px / weight 500, line-height 27px, bottom border `#fdfdfd`. Ingredient
|
||||
rows 40.66px tall, cell padding 8px 0 (name cell +10px left). Quantity
|
||||
number 15px / weight 300, unit 14px. Name link 15px / weight 400,
|
||||
`rgba(0,0,0,.87)`.
|
||||
|
||||
### Right column (tabs + method)
|
||||
|
||||
- Tab bar: 48px, at the top of the column. Tabs: icon 24px in a 45px box
|
||||
(8px padding, 5px right margin), label 13px / weight 400 `#050841`,
|
||||
padding 0 10px 0 0, 1px `#f3f3f3` right border between tabs.
|
||||
Active: background `#f1f5fe` + 1px `#3d5df6` bottom border.
|
||||
- Panel heading row: h2 22px / weight 700, line-height 33px; step count
|
||||
16px / weight 700 `#a5a9c1` with 8px left margin; 34px bottom margin.
|
||||
- Step section headings: 18px / weight 500, line-height 27px.
|
||||
- Steps: number 16px / weight 700, text 16px / weight 300, `#050841`;
|
||||
separators 1px `#f3f3f3` with 16px vertical margins.
|
||||
- Additional details: heading 18px / weight 500; label 14px / weight 500,
|
||||
value 15px / weight 500, `#050841`.
|
||||
|
||||
### Tab icons (Material paths, white-on-inherit)
|
||||
|
||||
- Prep Method: list icon, viewBox 24 (`M4 10.5c-.83 0-1.5.67-1.5 1.5s.67
|
||||
1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5m0-6c-.83 0-1.5.67-1.5
|
||||
1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5m0 12c-.83 0-1.5.68-1.5
|
||||
1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5M7 19h14v-2H7zm0-6h14
|
||||
v-2H7zm0-8v2h14V5z`).
|
||||
- Cost: attach_money icon, viewBox 24.
|
||||
- UoM Equivalency: custom balance icon, viewBox 24.
|
||||
- Nutrition: custom heart icon, viewBox 20×18.
|
||||
### Cost panel (`/recipes/{id}/cost`, measured in the second pass)
|
||||
|
||||
- Panel padding `35px 32px 0` (same `#fbfbfb` column).
|
||||
- h2 22px / weight 700, line-height 33px; description 14px / weight 400
|
||||
`#95969c`; ≈20px gap before the list.
|
||||
- Column header row: padding `0 0 4px 14px`, bottom border `#f3f3f3`,
|
||||
labels 12px / weight 500 `#a5a9c1`; "Expand all | Collapse all" links
|
||||
12px / weight 500 `#3d5df6`.
|
||||
- Rows (MUI accordions): min-height 48px, summary padding `12px 32px`,
|
||||
name 15px / weight 400 `#050841`, cost value 15px / weight 400.
|
||||
Detail labels ("Purchase Item Name", etc.) 14px / weight 400 `#a5a9c1`.
|
||||
|
||||
### UoM Equivalency panel (`/recipes/{id}/equivalency`)
|
||||
|
||||
- h2 "U of M Equivalency" 22px / weight 700, line-height 33px.
|
||||
- Description 14px muted `#95969c`.
|
||||
- Equivalency matrix: three value columns (Weight / Volume / Each) with
|
||||
"=" separators; columns ≈124px wide, 79.5px tall.
|
||||
|
||||
### Nutrition tab
|
||||
|
||||
Gated ("Upgrade" teaser) on the measured account — no read-mode nutrition
|
||||
panel exists in meez to match.
|
||||
@@ -0,0 +1,37 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_locations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_counts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
counted_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_count_items (
|
||||
count_id TEXT NOT NULL REFERENCES inventory_counts(id) ON DELETE CASCADE,
|
||||
location_id TEXT REFERENCES inventory_locations(id),
|
||||
ingredient_id TEXT NOT NULL REFERENCES ingredients(id),
|
||||
quantity REAL NOT NULL,
|
||||
unit_id TEXT NOT NULL REFERENCES units(id),
|
||||
unit_cost REAL,
|
||||
extended_cost REAL,
|
||||
PRIMARY KEY (count_id, location_id, ingredient_id)
|
||||
);
|
||||
|
||||
-- Seed baseline standard locations if table is empty
|
||||
INSERT OR IGNORE INTO inventory_locations (id, name, position, deleted_at) VALUES
|
||||
('loc_walk_in', 'Walk-in Cooler', 1, NULL),
|
||||
('loc_dry_storage', 'Dry Storage', 2, NULL),
|
||||
('loc_freezer', 'Freezer', 3, NULL),
|
||||
('loc_bar', 'Bar & Service', 4, NULL),
|
||||
('loc_line', 'Prep Line', 5, NULL);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Soft delete parity for purchase items
|
||||
ALTER TABLE purchase_items ADD COLUMN deleted_at TEXT;
|
||||
Generated
+2
-2
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "recipe-book",
|
||||
"name": "formulation",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "recipe-book",
|
||||
"name": "formulation",
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^11.1.1",
|
||||
"@astrojs/preact": "6.0.2",
|
||||
|
||||
+5
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "recipe-book",
|
||||
"name": "formulation",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -19,6 +19,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -34,5 +35,8 @@
|
||||
"@types/node": "^22.10.0",
|
||||
"typescript": "5.9.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.2": true
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { backup, DatabaseSync } from "node:sqlite";
|
||||
|
||||
const source = path.resolve(process.cwd(), "var", "recipe-book.sqlite");
|
||||
const requestedTarget = process.argv[2];
|
||||
|
||||
if (!requestedTarget) {
|
||||
throw new Error("Usage: npm run db:backup -- /path/to/recipe-book.sqlite");
|
||||
}
|
||||
if (!fs.existsSync(source)) {
|
||||
throw new Error(`Database not found: ${source}`);
|
||||
}
|
||||
|
||||
const target = path.resolve(requestedTarget);
|
||||
if (target === source) {
|
||||
throw new Error("Backup target must differ from the live database.");
|
||||
}
|
||||
if (fs.existsSync(target)) {
|
||||
throw new Error(`Refusing to overwrite existing backup: ${target}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
const database = new DatabaseSync(source, { readOnly: true });
|
||||
try {
|
||||
await backup(database, target);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
console.log(`Backed up ${source} to ${target}`);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { dev } from "astro";
|
||||
|
||||
try {
|
||||
const server = await dev({
|
||||
configFile: "astro.app.config.mjs",
|
||||
server: {
|
||||
port: 4322,
|
||||
host: true
|
||||
}
|
||||
});
|
||||
console.log("Astro dev server is running on http://localhost:4322/app/");
|
||||
} catch (err) {
|
||||
console.error("Failed to start Astro dev server:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Meez Design Token & Style Extractor
|
||||
*
|
||||
* Paste this snippet into the DevTools Console while viewing Meez
|
||||
* (e.g. https://app.getmeez.com/home?type=recipe or a recipe detail page).
|
||||
* It will collect computed styles, layout metrics, and SVG icons and copy
|
||||
* a formatted JSON report to your clipboard.
|
||||
*/
|
||||
(() => {
|
||||
const getStyle = (el, prop) => el ? window.getComputedStyle(el).getPropertyValue(prop) : null;
|
||||
|
||||
const extractComponent = (selector, name) => {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return null;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
name,
|
||||
selector,
|
||||
dimensions: { width: rect.width, height: rect.height },
|
||||
typography: {
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
fontWeight: style.fontWeight,
|
||||
lineHeight: style.lineHeight,
|
||||
letterSpacing: style.letterSpacing,
|
||||
color: style.color,
|
||||
},
|
||||
surface: {
|
||||
backgroundColor: style.backgroundColor,
|
||||
borderRadius: style.borderRadius,
|
||||
border: `${style.borderWidth} ${style.borderStyle} ${style.borderColor}`,
|
||||
boxShadow: style.boxShadow,
|
||||
},
|
||||
spacing: {
|
||||
padding: style.padding,
|
||||
margin: style.margin,
|
||||
gap: style.gap,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Collect key UI elements on current page
|
||||
const report = {
|
||||
url: window.location.href,
|
||||
timestamp: new Date().toISOString(),
|
||||
global: {
|
||||
bodyBackground: getStyle(document.body, 'background-color'),
|
||||
fontFamily: getStyle(document.body, 'font-family'),
|
||||
fontSize: getStyle(document.body, 'font-size'),
|
||||
color: getStyle(document.body, 'color'),
|
||||
},
|
||||
components: {
|
||||
header: extractComponent('header, [role="banner"], .MuiAppBar-root', 'Header / Top Bar'),
|
||||
searchBox: extractComponent('input[type="search"], input[placeholder*="Search"]', 'Search Input'),
|
||||
newButton: extractComponent('button:has(svg), a:has(svg)', 'New Button'),
|
||||
tableHeader: extractComponent('[role="rowgroup"] [role="row"]:first-child, thead tr', 'Table Header'),
|
||||
tableRow: extractComponent('[role="rowgroup"] [role="row"]:not(:first-child), tbody tr:first-child', 'Table Data Row'),
|
||||
tabPillActive: extractComponent('.MuiChip-root, [role="tab"][aria-selected="true"]', 'Active Tab / Pill'),
|
||||
},
|
||||
svgIcons: Array.from(document.querySelectorAll('svg')).slice(0, 30).map((svg, idx) => ({
|
||||
index: idx,
|
||||
viewBox: svg.getAttribute('viewBox'),
|
||||
width: svg.getAttribute('width') || svg.clientWidth,
|
||||
height: svg.getAttribute('height') || svg.clientHeight,
|
||||
fill: getStyle(svg, 'fill') || getStyle(svg, 'color'),
|
||||
paths: Array.from(svg.querySelectorAll('path')).map(p => p.getAttribute('d')),
|
||||
ariaLabel: svg.getAttribute('aria-label') || svg.closest('button, a')?.getAttribute('aria-label') || ''
|
||||
}))
|
||||
};
|
||||
|
||||
console.log('=== MEEZ EXTRACTED TOKENS ===', report);
|
||||
const jsonStr = JSON.stringify(report, null, 2);
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(jsonStr).then(() => {
|
||||
console.log('✅ Tokens copied to clipboard!');
|
||||
}).catch(() => {
|
||||
console.log('Copy to clipboard failed. Access report via window.__meezReport');
|
||||
});
|
||||
}
|
||||
window.__meezReport = report;
|
||||
return report;
|
||||
})();
|
||||
@@ -3,6 +3,8 @@ import path from "node:path";
|
||||
|
||||
const json = (value, fallback) => { try { return JSON.parse(value); } catch { return fallback; } };
|
||||
|
||||
export const titleCase = (input) => input.split(/\s+/).map((word) => { const index = word.search(/\p{L}/u); return index === -1 ? word : word.slice(0, index) + word[index].toLocaleUpperCase() + word.slice(index + 1); }).join(" ");
|
||||
|
||||
export function createSiteProjection(database) {
|
||||
const units = database.prepare("SELECT * FROM units ORDER BY id").all().map((row) => ({ schema_version:2,id:row.id,name:row.name,symbol:row.symbol,dimension:row.dimension,system:row.system,...(row.base_unit_id?{base_conversion:{base_unit_id:row.base_unit_id,factor:row.factor,...(row.offset!=null?{offset:row.offset}:{})}}:{}) }));
|
||||
const aliasQuery=database.prepare("SELECT name,kind FROM ingredient_aliases WHERE ingredient_id=? ORDER BY name");
|
||||
@@ -12,7 +14,7 @@ export function createSiteProjection(database) {
|
||||
const mappingIdsQuery=database.prepare("SELECT id,mapping_type FROM source_mappings WHERE subject_type='ingredient' AND subject_id=? AND status='reviewed' ORDER BY id");
|
||||
const ingredients = database.prepare("SELECT * FROM ingredients ORDER BY id").all().map((row) => {
|
||||
const mappings=mappingIdsQuery.all(row.id), source=json(row.source_json,"{}");
|
||||
return { schema_version:row.schema_version,id:row.id,name:row.name,...(row.description?{description:row.description}:{}),status:row.status,categories:json(row.categories_json,[]),tags:json(row.tags_json,[]),
|
||||
return { schema_version:row.schema_version,id:row.id,name:titleCase(row.name),...(row.description?{description:row.description}:{}),status:row.status,categories:json(row.categories_json,[]),tags:json(row.tags_json,[]),
|
||||
aliases:aliasQuery.all(row.id),
|
||||
density_measurements:densityQuery.all(row.id).map((value)=>({id:value.id,mass:{quantity:value.mass_quantity,unit_id:value.mass_unit_id},volume:{quantity:value.volume_quantity,unit_id:value.volume_unit_id},...(value.temperature_c!=null?{temperature_c:value.temperature_c}:{}),...(value.state?{state:value.state}:{}),source:json(value.source_json,{})})),
|
||||
measure_conversions:conversionQuery.all(row.id).map((value)=>({id:value.id,from:{quantity:value.from_quantity,unit_id:value.from_unit_id},to:{quantity:value.to_quantity,unit_id:value.to_unit_id},...(value.state?{state:value.state}:{}),source:json(value.source_json,{})})),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { parseIngredientsWithOllama } from "../../../../../lib/ingredient-parser";
|
||||
import { readOnlyMode } from "../../../../../lib/runtime";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
if (readOnlyMode) return Response.json({ error: "Ingredient parsing is unavailable in read-only mode." }, { status: 403 });
|
||||
try {
|
||||
const body = await request.json() as { text?: unknown };
|
||||
if (typeof body.text !== "string") return Response.json({ error: "Ingredient text is required." }, { status: 400 });
|
||||
if (body.text.length > 20_000) return Response.json({ error: "Ingredient text is too long." }, { status: 413 });
|
||||
return Response.json(await parseIngredientsWithOllama(body.text));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.name === "TimeoutError"
|
||||
? "Ingredient parser timed out. Try again."
|
||||
: error instanceof Error ? error.message : "Unable to parse ingredients.";
|
||||
return Response.json({ error: message }, { status: 502 });
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,452 @@
|
||||
---
|
||||
export const prerender=false;
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import {openDatabase,refreshSiteProjection} from "../../../lib/database";
|
||||
const database=openDatabase({readOnly:false});if(!database)return Astro.redirect("/app/",303);
|
||||
if(Astro.request.method==="POST"){const form=await Astro.request.formData(),type=String(form.get("type")),id=String(form.get("id"));const tables:{[key:string]:string}={recipe:"recipes",ingredient:"ingredients",book:"collections"};if(tables[type])database.prepare(`UPDATE ${tables[type]} SET deleted_at=NULL${type==="ingredient"?",status='active'":""} WHERE id=?`).run(id);refreshSiteProjection(database);database.close();return Astro.redirect("/app/archive/",303);}
|
||||
const items=[...(database.prepare("SELECT id,title name,deleted_at FROM recipes WHERE deleted_at IS NOT NULL").all() as any[]).map(x=>({...x,type:"recipe"})),...(database.prepare("SELECT id,name,deleted_at FROM ingredients WHERE deleted_at IS NOT NULL").all() as any[]).map(x=>({...x,type:"ingredient"})),...(database.prepare("SELECT id,name,deleted_at FROM collections WHERE deleted_at IS NOT NULL").all() as any[]).map(x=>({...x,type:"book"}))].sort((a,b)=>a.name.localeCompare(b.name));database.close();
|
||||
import DetailUtility from "../../../components/DetailUtility.astro";
|
||||
import {
|
||||
openDatabase,
|
||||
permanentlyDeleteArchivedItems,
|
||||
restoreArchivedItems,
|
||||
} from "../../../lib/database";
|
||||
import { titleCase } from "../../../lib/format";
|
||||
import { readOnlyMode } from "../../../lib/runtime";
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Astro.redirect("/app/", 303);
|
||||
|
||||
let error = "";
|
||||
if (Astro.request.method === "POST") {
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
const intent = String(form.get("intent") ?? "restore");
|
||||
|
||||
if (intent === "restore") {
|
||||
const type = String(form.get("type"));
|
||||
const id = String(form.get("id"));
|
||||
restoreArchivedItems(database, [{ id, type }]);
|
||||
database.close();
|
||||
return Astro.redirect("/app/archive/", 303);
|
||||
}
|
||||
|
||||
if (intent === "delete") {
|
||||
const type = String(form.get("type"));
|
||||
const id = String(form.get("id"));
|
||||
permanentlyDeleteArchivedItems(database, [{ id, type }]);
|
||||
database.close();
|
||||
return Astro.redirect("/app/archive/", 303);
|
||||
}
|
||||
|
||||
if (intent === "batch_restore") {
|
||||
const selectedItems = form.getAll("selected_item").map((val) => {
|
||||
const [type, id] = String(val).split(":", 2);
|
||||
return { type, id };
|
||||
});
|
||||
if (selectedItems.length > 0) {
|
||||
restoreArchivedItems(database, selectedItems);
|
||||
}
|
||||
database.close();
|
||||
return Astro.redirect("/app/archive/", 303);
|
||||
}
|
||||
|
||||
if (intent === "batch_delete") {
|
||||
const selectedItems = form.getAll("selected_item").map((val) => {
|
||||
const [type, id] = String(val).split(":", 2);
|
||||
return { type, id };
|
||||
});
|
||||
if (selectedItems.length > 0) {
|
||||
permanentlyDeleteArchivedItems(database, selectedItems);
|
||||
}
|
||||
database.close();
|
||||
return Astro.redirect("/app/archive/", 303);
|
||||
}
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "Action failed.";
|
||||
}
|
||||
}
|
||||
|
||||
const recipes = (
|
||||
database
|
||||
.prepare(
|
||||
"SELECT id, title AS name, deleted_at FROM recipes WHERE deleted_at IS NOT NULL"
|
||||
)
|
||||
.all() as any[]
|
||||
).map((x) => ({ ...x, type: "recipe" as const }));
|
||||
|
||||
const ingredients = (
|
||||
database
|
||||
.prepare(
|
||||
"SELECT id, name, deleted_at FROM ingredients WHERE deleted_at IS NOT NULL"
|
||||
)
|
||||
.all() as any[]
|
||||
).map((x) => ({ ...x, type: "ingredient" as const }));
|
||||
|
||||
const books = (
|
||||
database
|
||||
.prepare(
|
||||
"SELECT id, name, deleted_at FROM collections WHERE deleted_at IS NOT NULL"
|
||||
)
|
||||
.all() as any[]
|
||||
).map((x) => ({ ...x, type: "book" as const }));
|
||||
|
||||
const purchases = (
|
||||
database
|
||||
.prepare(
|
||||
"SELECT id, name, deleted_at FROM purchase_items WHERE deleted_at IS NOT NULL"
|
||||
)
|
||||
.all() as any[]
|
||||
).map((x) => ({ ...x, type: "purchase" as const }));
|
||||
|
||||
const allItems = [...recipes, ...ingredients, ...books, ...purchases].sort(
|
||||
(a, b) => a.name.localeCompare(b.name)
|
||||
);
|
||||
database.close();
|
||||
|
||||
const requestedFilter = Astro.url.searchParams.get("type") ?? "all";
|
||||
const query = (Astro.url.searchParams.get("q") ?? "").trim().toLowerCase();
|
||||
|
||||
const filteredItems = allItems.filter((item) => {
|
||||
if (requestedFilter !== "all" && item.type !== requestedFilter) return false;
|
||||
if (query && !item.name.toLowerCase().includes(query)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
function formatDeleteDate(dateStr: string | null) {
|
||||
if (!dateStr) return "";
|
||||
try {
|
||||
const d = new Date(
|
||||
dateStr.includes("Z") || dateStr.includes("T")
|
||||
? dateStr
|
||||
: `${dateStr.replace(" ", "T")}Z`
|
||||
);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
---
|
||||
<BaseLayout title="Archive"><section class="shell archive-workspace"><header><div><a href="/app/">← All items</a><h1>Archive</h1><p>Restore recipes, ingredients, and recipe books removed from the active workspace.</p></div></header>{items.length?items.map(item=><form method="post" class="archive-row"><input type="hidden" name="type" value={item.type}/><input type="hidden" name="id" value={item.id}/><span class={`workspace-pill-icon ${item.type}`}>{item.type==="recipe"?"▦":item.type==="book"?"▣":"●"}</span><span><strong>{item.name}</strong><small>{item.type} · deleted {item.deleted_at}</small></span><button>Restore</button></form>):<p class="empty-state">Nothing has been archived.</p>}</section></BaseLayout>
|
||||
|
||||
<BaseLayout title="Archive" immersive>
|
||||
<div class="archive-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="archive-workspace">
|
||||
{error && <div class="notice archive-error-banner">{error}</div>}
|
||||
|
||||
<header class="archive-header">
|
||||
<div class="archive-header-left">
|
||||
<nav class="archive-breadcrumbs">
|
||||
<a href="/app/">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>All items</span>
|
||||
</a>
|
||||
</nav>
|
||||
<h1>Archive</h1>
|
||||
<p class="archive-subtitle">
|
||||
Restore recipes, ingredients, recipe books, and purchase items or permanently purge them.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="archive-toolbar">
|
||||
<nav class="archive-filter-chips">
|
||||
<a href="/app/archive/" class:list={["archive-chip", { active: requestedFilter === "all" }]}>
|
||||
<span>All</span>
|
||||
<span class="chip-count">{allItems.length}</span>
|
||||
</a>
|
||||
<a href="/app/archive/?type=recipe" class:list={["archive-chip", { active: requestedFilter === "recipe" }]}>
|
||||
<span>Recipes</span>
|
||||
<span class="chip-count">{recipes.length}</span>
|
||||
</a>
|
||||
<a href="/app/archive/?type=ingredient" class:list={["archive-chip", { active: requestedFilter === "ingredient" }]}>
|
||||
<span>Ingredients</span>
|
||||
<span class="chip-count">{ingredients.length}</span>
|
||||
</a>
|
||||
<a href="/app/archive/?type=book" class:list={["archive-chip", { active: requestedFilter === "book" }]}>
|
||||
<span>Recipe books</span>
|
||||
<span class="chip-count">{books.length}</span>
|
||||
</a>
|
||||
<a href="/app/archive/?type=purchase" class:list={["archive-chip", { active: requestedFilter === "purchase" }]}>
|
||||
<span>Purchase items</span>
|
||||
<span class="chip-count">{purchases.length}</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{filteredItems.length > 0 ? (
|
||||
<form method="post" id="archive-batch-form">
|
||||
<input type="hidden" name="intent" id="batch-intent" value="batch_restore" />
|
||||
|
||||
<div class="archive-table">
|
||||
<div class="archive-table-head">
|
||||
{!readOnlyMode && (
|
||||
<span class="head-col select">
|
||||
<input type="checkbox" id="select-all-archive" aria-label="Select all archived items" />
|
||||
</span>
|
||||
)}
|
||||
<span class="head-col type">Type</span>
|
||||
<span class="head-col name">Item Name</span>
|
||||
<span class="head-col date">Deleted Date</span>
|
||||
<span class="head-col action">Action</span>
|
||||
</div>
|
||||
|
||||
<div class="archive-table-body">
|
||||
{filteredItems.map((item) => (
|
||||
<div class="archive-table-row">
|
||||
{!readOnlyMode && (
|
||||
<div class="archive-select-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="selected_item"
|
||||
value={`${item.type}:${item.id}`}
|
||||
class="archive-item-checkbox"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="archive-type-cell">
|
||||
<span class:list={["workspace-pill-icon", item.type]}>
|
||||
{item.type === "recipe" && (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z" transform="scale(1.1, 1.1) translate(4px, 3.5px)"/>
|
||||
</svg>
|
||||
)}
|
||||
{item.type === "ingredient" && (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z" transform="scale(1.3, 1.3) translate(2.5px, 1px)"/>
|
||||
</svg>
|
||||
)}
|
||||
{item.type === "book" && (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765 2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z" transform="scale(1.1, 1.1) translate(3.5px, 2.5px)"/>
|
||||
</svg>
|
||||
)}
|
||||
{item.type === "purchase" && (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="archive-name-cell">
|
||||
<strong class="archive-item-title">{titleCase(item.name)}</strong>
|
||||
<span class="archive-item-type">{item.type}</span>
|
||||
</div>
|
||||
|
||||
<div class="archive-date-cell">
|
||||
<span class="archive-date-badge">Deleted {formatDeleteDate(item.deleted_at)}</span>
|
||||
</div>
|
||||
|
||||
<div class="archive-action-cell">
|
||||
{!readOnlyMode && (
|
||||
<div class="row-single-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="archive-restore-btn"
|
||||
onclick={`this.form.querySelector('#batch-intent').value='restore'; const hidden = document.createElement('input'); hidden.type='hidden'; hidden.name='type'; hidden.value='${item.type}'; this.form.appendChild(hidden); const hiddenId = document.createElement('input'); hiddenId.type='hidden'; hiddenId.name='id'; hiddenId.value='${item.id}'; this.form.appendChild(hiddenId);`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
|
||||
<path d="M3 3v5h5"></path>
|
||||
</svg>
|
||||
<span>Restore</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="archive-delete-btn"
|
||||
title="Permanently Delete"
|
||||
onclick={`if(!confirm('Permanently delete ${item.name}? This cannot be undone.')) return false; this.form.querySelector('#batch-intent').value='delete'; const hidden = document.createElement('input'); hidden.type='hidden'; hidden.name='type'; hidden.value='${item.type}'; this.form.appendChild(hidden); const hiddenId = document.createElement('input'); hiddenId.type='hidden'; hiddenId.name='id'; hiddenId.value='${item.id}'; this.form.appendChild(hiddenId);`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky Batch Actions Floating Bar -->
|
||||
{!readOnlyMode && (
|
||||
<div id="archive-floating-bar" class="archive-floating-bar hidden">
|
||||
<span id="selected-count-label">0 items selected</span>
|
||||
<div class="floating-bar-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="batch-restore-btn"
|
||||
onclick="document.getElementById('batch-intent').value='batch_restore'"
|
||||
>
|
||||
Restore Selected
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="batch-delete-btn"
|
||||
onclick="if(!confirm('Permanently delete the selected items? Items will be purged from the database.')) return false; document.getElementById('batch-intent').value='batch_delete';"
|
||||
>
|
||||
Permanently Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<div class="archive-empty-state">
|
||||
<div class="empty-icon-circle">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<polyline points="21 8 21 21 3 21 3 8"></polyline>
|
||||
<rect x="1" y="3" width="22" height="5"></rect>
|
||||
<line x1="10" y1="12" x2="14" y2="12"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Nothing in the archive</h3>
|
||||
<p>Archived recipes, ingredients, and recipe books will appear here and can be restored anytime.</p>
|
||||
<a href="/app/" class="archive-back-btn">Return to workspace</a>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script is:inline>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const selectAll = document.getElementById("select-all-archive");
|
||||
const checkboxes = document.querySelectorAll(".archive-item-checkbox");
|
||||
const floatingBar = document.getElementById("archive-floating-bar");
|
||||
const countLabel = document.getElementById("selected-count-label");
|
||||
|
||||
function syncBatchBar() {
|
||||
const checkedCount = document.querySelectorAll(".archive-item-checkbox:checked").length;
|
||||
if (checkedCount > 0) {
|
||||
floatingBar?.classList.remove("hidden");
|
||||
if (countLabel) countLabel.textContent = `${checkedCount} item${checkedCount === 1 ? "" : "s"} selected`;
|
||||
} else {
|
||||
floatingBar?.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener("change", () => {
|
||||
checkboxes.forEach((cb) => {
|
||||
cb.checked = selectAll.checked;
|
||||
});
|
||||
syncBatchBar();
|
||||
});
|
||||
}
|
||||
|
||||
checkboxes.forEach((cb) => {
|
||||
cb.addEventListener("change", syncBatchBar);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.archive-error-banner {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border-left: 4px solid #ef4444;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.archive-table-head,
|
||||
.archive-table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 34px minmax(16rem, 1fr) 10rem 140px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.archive-select-cell,
|
||||
.head-col.select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.row-single-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.archive-delete-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--line);
|
||||
background: white;
|
||||
color: var(--muted);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.archive-delete-btn:hover {
|
||||
border-color: #fca5a5;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Floating Bar */
|
||||
.archive-floating-bar {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #050841;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 10px 25px rgba(5, 8, 65, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
z-index: 1000;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.archive-floating-bar.hidden {
|
||||
display: none;
|
||||
}
|
||||
.floating-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.batch-restore-btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--blue);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.batch-restore-btn:hover {
|
||||
background: var(--blue-hover);
|
||||
}
|
||||
.batch-delete-btn {
|
||||
padding: 6px 14px;
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.batch-delete-btn:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,28 +2,51 @@
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import EntityDirectory from "../../../components/EntityDirectory";
|
||||
const TYPE_ICONS:Record<"recipe"|"ingredient"|"book"|"purchase"|"inventory",string> = {
|
||||
recipe:"M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z",
|
||||
ingredient:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z",
|
||||
book:"M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765 2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z",
|
||||
purchase:"M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z",
|
||||
inventory:"M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 10h2v7H7zm4-3h2v10h-2zm4 6h2v4h-2z"
|
||||
};
|
||||
const TYPE_ICON_TRANSFORMS:Record<"recipe"|"ingredient"|"book"|"purchase"|"inventory",string|undefined> = {
|
||||
recipe:"scale(1.1, 1.1) translate(4px, 3.5px)",
|
||||
ingredient:"scale(1.3, 1.3) translate(2.5px, 1px)",
|
||||
book:"scale(1.1, 1.1) translate(3.5px, 2.5px)",
|
||||
purchase:undefined,
|
||||
inventory:undefined
|
||||
};
|
||||
import { openDatabase } from "../../../lib/database";
|
||||
import { readOnlyMode } from "../../../lib/runtime";
|
||||
import { titleCase } from "../../../lib/format";
|
||||
import type {
|
||||
DirectoryBookRow,
|
||||
DirectoryIngredientRow,
|
||||
DirectoryPurchaseRow,
|
||||
DirectoryRecipeRow,
|
||||
} from "../../../lib/repository";
|
||||
|
||||
const database = openDatabase();
|
||||
if (!database) return new Response("Database unavailable", { status:503 });
|
||||
const recipes=database.prepare(`SELECT r.id,r.title,r.yield_quantity,r.yield_unit_id,
|
||||
if (!database) return new Response("Database unavailable", { status: 503 });
|
||||
const recipes = database.prepare(`SELECT r.id,r.title,r.yield_quantity,r.yield_unit_id,
|
||||
(SELECT count(*) FROM recipe_items ri WHERE ri.recipe_id=r.id) item_count,
|
||||
(SELECT count(*) FROM recipe_steps rs WHERE rs.recipe_id=r.id AND rs.instruction LIKE 'TODO:%') placeholder_count
|
||||
FROM recipes r WHERE r.deleted_at IS NULL ORDER BY r.title`).all() as any[];
|
||||
const ingredients=database.prepare(`SELECT i.id,i.name,i.status,
|
||||
FROM recipes r WHERE r.deleted_at IS NULL ORDER BY r.title`).all() as unknown as DirectoryRecipeRow[];
|
||||
const ingredients = database.prepare(`SELECT i.id,i.name,i.status,
|
||||
(SELECT count(*) FROM recipe_items r WHERE r.ingredient_id=i.id) recipe_count,
|
||||
(SELECT count(*) FROM price_observations po JOIN purchase_items p ON p.id=po.purchase_item_id WHERE p.ingredient_id=i.id) price_count,
|
||||
(SELECT count(*) FROM source_mappings m WHERE m.subject_type='ingredient' AND m.subject_id=i.id AND m.mapping_type='nutrition' AND m.status='reviewed') nutrition_count
|
||||
FROM ingredients i WHERE i.deleted_at IS NULL ORDER BY i.name`).all() as any[];
|
||||
const books=database.prepare("SELECT c.id,c.name,c.description,(SELECT count(*) FROM collection_recipes r WHERE r.collection_id=c.id) recipe_count FROM collections c WHERE c.deleted_at IS NULL ORDER BY c.name").all() as any[];
|
||||
const purchases=database.prepare(`SELECT p.id,p.ingredient_id,p.name,p.supplier_id,p.status,i.name ingredient_name,p.package_quantity,p.package_unit_id,
|
||||
FROM ingredients i WHERE i.deleted_at IS NULL ORDER BY i.name`).all() as unknown as DirectoryIngredientRow[];
|
||||
const books = database.prepare("SELECT c.id,c.name,c.description,(SELECT count(*) FROM collection_recipes r WHERE r.collection_id=c.id) recipe_count FROM collections c WHERE c.deleted_at IS NULL ORDER BY c.name").all() as unknown as DirectoryBookRow[];
|
||||
const purchases = database.prepare(`SELECT p.id,p.ingredient_id,p.name,p.supplier_id,p.status,i.name ingredient_name,p.package_quantity,p.package_unit_id,
|
||||
(SELECT amount FROM price_observations x WHERE x.purchase_item_id=p.id ORDER BY effective_at DESC LIMIT 1) latest_price
|
||||
FROM purchase_items p JOIN ingredients i ON i.id=p.ingredient_id ORDER BY p.name`).all() as any[];
|
||||
FROM purchase_items p JOIN ingredients i ON i.id=p.ingredient_id ORDER BY p.name`).all() as unknown as DirectoryPurchaseRow[];
|
||||
const inventoryCounts = database.prepare("SELECT count(*) as c FROM inventory_counts WHERE deleted_at IS NULL").get() as { c: number } | undefined;
|
||||
database.close();
|
||||
|
||||
const requested=Astro.url.searchParams.get("type")??"ingredient";
|
||||
const type=["recipe","ingredient","book","purchase"].includes(requested)?requested:"ingredient";
|
||||
const requested=Astro.url.searchParams.get("type");
|
||||
if (requested === "inventory") return Astro.redirect("/app/inventory/", 303);
|
||||
const type=["recipe","ingredient","book","purchase"].includes(requested??"")?requested:undefined;
|
||||
const query=(Astro.url.searchParams.get("q")??"").trim();
|
||||
const normalizedQuery=query.toLocaleLowerCase();
|
||||
const validSearchTypes=["recipe","ingredient","book","purchase"];
|
||||
@@ -33,61 +56,67 @@ const attention=Astro.url.searchParams.get("attention")==="1", missingCost=Astro
|
||||
const filtering=attention||missingCost||noUsda||unused||emptyRecipe||placeholderSteps;
|
||||
const filteredIngredients=ingredients.filter((ingredient)=>{if(!filtering)return true;const selected=[missingCost&&ingredient.price_count===0,noUsda&&ingredient.nutrition_count===0,unused&&ingredient.recipe_count===0].filter(Boolean);return missingCost||noUsda||unused?selected.length>0:ingredient.price_count===0||ingredient.nutrition_count===0||ingredient.recipe_count===0;});
|
||||
const filteredRecipes=recipes.filter(recipe=>!filtering||(emptyRecipe&&recipe.item_count===0)||(placeholderSteps&&recipe.placeholder_count>0)||(!emptyRecipe&&!placeholderSteps&&(recipe.item_count===0||recipe.placeholder_count>0)));
|
||||
const tabs=[
|
||||
{type:"recipe",label:"Recipes",count:recipes.length,icon:"▦",kind:"recipe"},
|
||||
{type:"ingredient",label:"Ingredients",count:ingredients.length,icon:"●",kind:"ingredient"},
|
||||
{type:"book",label:"Recipe books",count:books.length,icon:"▣",kind:"book"},
|
||||
{type:"purchase",label:"Purchase items",count:purchases.length,icon:"$",kind:"purchase"},
|
||||
const FILTER_ICON_PATH="M18 6.0201C18 4.81608 17.1873 3.79266 16.0736 3.46156L16.0736 0.722412C16.0736 0.301005 15.7425 -9.86801e-08 15.3211 -1.171e-07C14.8996 -1.35521e-07 14.5685 0.331105 14.5685 0.752512L14.5685 3.49166C13.4548 3.79266 12.6421 4.84618 12.6421 6.0502C12.6421 7.28432 13.4548 8.30774 14.5685 8.63884L14.5685 19.7459C14.5685 20.1673 14.8996 20.4984 15.3211 20.4984C15.7425 20.4984 16.0736 20.1673 16.0736 19.7459L16.0736 8.63884C17.1873 8.27764 18 7.25422 18 6.0201ZM16.495 6.0502C16.495 6.68231 15.9833 7.22412 15.3211 7.22412C14.6588 7.22412 14.1471 6.68231 14.1471 6.0502C14.1471 5.41809 14.6588 4.87628 15.3211 4.87628C15.9833 4.87628 16.495 5.41809 16.495 6.0502Z";
|
||||
const tabs:Array<{type:string;label:string;count:number;kind:"recipe"|"ingredient"|"book"|"purchase"|"inventory";href?:string}>=[
|
||||
{type:"recipe",label:"Recipes",count:recipes.length,kind:"recipe"},
|
||||
{type:"ingredient",label:"Ingredients",count:ingredients.length,kind:"ingredient"},
|
||||
{type:"book",label:"Recipe books",count:books.length,kind:"book"},
|
||||
{type:"purchase",label:"Purchase items",count:purchases.length,kind:"purchase"},
|
||||
{type:"inventory",label:"Inventory",count:inventoryCounts?.c ?? 0,kind:"inventory",href:"/app/inventory/"},
|
||||
];
|
||||
const allSearchResults=normalizedQuery ? [
|
||||
...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"recipe",label:"Recipe",icon:"▦",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})),
|
||||
...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"ingredient",label:"Ingredient",icon:"●",name:item.name,detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})),
|
||||
...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"book",label:"Recipe book",icon:"▣",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:"/app/?type=book"})),
|
||||
...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"purchase",label:"Purchase item",icon:"$",name:item.name,detail:item.ingredient_name,href:`/app/ingredients/${item.ingredient_id}/#costs`})),
|
||||
...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"recipe" as const,label:"Recipe",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})),
|
||||
...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"ingredient" as const,label:"Ingredient",name:titleCase(item.name),detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})),
|
||||
...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"book" as const,label:"Recipe book",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/recipe-books/${item.id}/`})),
|
||||
...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"purchase" as const,label:"Purchase item",name:item.name,detail:titleCase(item.ingredient_name),href:`/app/ingredients/${item.ingredient_id}/#costs`})),
|
||||
].sort((a,b)=>a.name.localeCompare(b.name)):[];
|
||||
const searchResults=filteringSearchTypes?allSearchResults.filter((result)=>selectedSearchTypes.includes(result.kind)):allSearchResults;
|
||||
const ingredientRows=filteredIngredients.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.id}/`,kind:"ingredient" as const,icon:"●"}));
|
||||
const recipeRows=filteredRecipes.map(item=>({id:item.id,name:item.title,href:`/app/recipes/${item.id}/`,kind:"recipe" as const,icon:"▦"}));
|
||||
const bookRows=books.map(item=>({id:item.id,name:item.name,href:`/app/recipe-books/${item.id}/`,kind:"book" as const,icon:"▣"}));
|
||||
const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.ingredient_id}/#costs`,kind:"purchase" as const,icon:"$"}));
|
||||
const searchRows=searchResults.map(({id,kind,name,href,label,detail})=>({id,name,href,kind,detail:`${label} · ${detail}`}));
|
||||
const ingredientRows=filteredIngredients.map(item=>({id:item.id,name:titleCase(item.name),href:`/app/ingredients/${item.id}/`,kind:"ingredient" as const}));
|
||||
const recipeRows=filteredRecipes.map(item=>({id:item.id,name:item.title,href:`/app/recipes/${item.id}/`,kind:"recipe" as const}));
|
||||
const bookRows=books.map(item=>({id:item.id,name:item.name,href:`/app/recipe-books/${item.id}/`,kind:"book" as const}));
|
||||
const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.ingredient_id}/#costs`,kind:"purchase" as const}));
|
||||
---
|
||||
<BaseLayout title="Recipe management"><section class="shell directory-workspace">
|
||||
<div class="workspace-search-tools">
|
||||
<BaseLayout title="Recipe management"><div class="workspace-search-tools"><div class="workspace-search-tools-inner">
|
||||
<form class="workspace-global-search" method="get" action="/app/" role="search">
|
||||
<input type="hidden" name="type" value={type}/>
|
||||
<input type="hidden" name="type" value={type??""}/>
|
||||
{selectedSearchTypes.map((selectedType)=><input type="hidden" name="item_type" value={selectedType}/>)}
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input type="search" name="q" value={query} placeholder="Search recipes, ingredients, recipe books, and purchase items" aria-label="Search all items" autofocus={Boolean(query)}/>
|
||||
{query&&<a href={`/app/?type=${type}`} aria-label="Clear search">×</a>}
|
||||
<svg class="search-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
<input type="search" name="q" value={query} placeholder="Search " aria-label="Search all items" autofocus={Boolean(query)}/>
|
||||
{query&&<a href={type?`/app/?type=${type}`:"/app/"} aria-label="Clear search">×</a>}
|
||||
</form>
|
||||
<details class="search-type-filter" open={filteringSearchTypes}>
|
||||
<summary>☷ Item type{filteringSearchTypes?` · ${selectedSearchTypes.length}`:""}</summary>
|
||||
<form method="get" action="/app/"><input type="hidden" name="type" value={type}/><input type="hidden" name="q" value={query}/>
|
||||
<summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Item type{filteringSearchTypes?` · ${selectedSearchTypes.length}`:""}</summary>
|
||||
<form method="get" action="/app/"><input type="hidden" name="type" value={type??""}/><input type="hidden" name="q" value={query}/>
|
||||
<label><input type="checkbox" name="item_type" value="recipe" checked={selectedSearchTypes.includes("recipe")}/> Recipes</label>
|
||||
<label><input type="checkbox" name="item_type" value="ingredient" checked={selectedSearchTypes.includes("ingredient")}/> Ingredients</label>
|
||||
<label><input type="checkbox" name="item_type" value="book" checked={selectedSearchTypes.includes("book")}/> Recipe books</label>
|
||||
<label><input type="checkbox" name="item_type" value="purchase" checked={selectedSearchTypes.includes("purchase")}/> Purchase items</label>
|
||||
<div><button>Apply</button>{filteringSearchTypes&&<a href={`/app/?type=${type}&q=${encodeURIComponent(query)}`}>All types</a>}</div>
|
||||
<div><button>Apply</button>{filteringSearchTypes&&<a href={type?`/app/?type=${type}&q=${encodeURIComponent(query)}`:`/app/?q=${encodeURIComponent(query)}`}>All types</a>}</div>
|
||||
</form>
|
||||
</details>
|
||||
{!readOnlyMode&&<details class="workspace-new-menu">
|
||||
<summary><span class="new-trigger-plus" aria-hidden="true">+</span><span>New</span></summary>
|
||||
<summary><span class="new-trigger-plus" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg></span><span class="new-trigger-label">New</span></summary>
|
||||
<nav aria-label="Create new item">
|
||||
<a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true">▦</span><strong>Recipe</strong></a>
|
||||
<a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true">▣</span><strong>Recipe book</strong></a>
|
||||
<a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill="currentColor" d={TYPE_ICONS.recipe} style={TYPE_ICON_TRANSFORMS.recipe?{transform:TYPE_ICON_TRANSFORMS.recipe}:undefined}/></svg></span><strong>Recipe</strong></a>
|
||||
<a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill="currentColor" d={TYPE_ICONS.book} style={TYPE_ICON_TRANSFORMS.book?{transform:TYPE_ICON_TRANSFORMS.book}:undefined}/></svg></span><strong>Recipe book</strong></a>
|
||||
</nav>
|
||||
</details>}
|
||||
</div>
|
||||
</div>
|
||||
<nav class="workspace-pills" aria-label="Workspaces">
|
||||
{tabs.map((tab)=><a class:list={{active:type===tab.type}} href={`/app/?type=${tab.type}`}><span class={`workspace-pill-icon ${tab.kind}`}>{tab.icon}</span>{tab.label} <small>{tab.count}</small></a>)}
|
||||
{type==="ingredient"&&<details class="filter-menu" open={filtering}><summary>☷ Filter{filtering?` · ${filteredIngredients.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="ingredient"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="missing_cost" value="1" checked={missingCost}/> Missing cost</label><label><input type="checkbox" name="no_usda" value="1" checked={noUsda}/> No USDA map</label><label><input type="checkbox" name="unused" value="1" checked={unused}/> Unused ingredient</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=ingredient">Clear</a>}</div></form></details>}
|
||||
{type==="recipe"&&<details class="filter-menu" open={filtering}><summary>☷ Filter{filtering?` · ${filteredRecipes.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="recipe"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="empty_recipe" value="1" checked={emptyRecipe}/> Empty recipe</label><label><input type="checkbox" name="placeholder_steps" value="1" checked={placeholderSteps}/> Placeholder instructions</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=recipe">Clear</a>}</div></form></details>}
|
||||
<section class="shell directory-workspace">
|
||||
<nav class="workspace-pills" aria-label="Workspaces">
|
||||
{tabs.map((tab)=><a class:list={{active:type===tab.type}} href={tab.href ?? `/app/?type=${tab.type}`}><span class={`workspace-pill-icon ${tab.kind}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d={TYPE_ICONS[tab.kind]} style={TYPE_ICON_TRANSFORMS[tab.kind]?{transform:TYPE_ICON_TRANSFORMS[tab.kind]}:undefined}/></svg></span><span>{tab.label}</span><small>{tab.count}</small></a>)}
|
||||
{type==="ingredient"&&<details class="filter-menu" open={filtering}><summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Filter{filtering?` · ${filteredIngredients.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="ingredient"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="missing_cost" value="1" checked={missingCost}/> Missing cost</label><label><input type="checkbox" name="no_usda" value="1" checked={noUsda}/> No USDA map</label><label><input type="checkbox" name="unused" value="1" checked={unused}/> Unused ingredient</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=ingredient">Clear</a>}</div></form></details>}
|
||||
{type==="recipe"&&<details class="filter-menu" open={filtering}><summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Filter{filtering?` · ${filteredRecipes.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="recipe"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="empty_recipe" value="1" checked={emptyRecipe}/> Empty recipe</label><label><input type="checkbox" name="placeholder_steps" value="1" checked={placeholderSteps}/> Placeholder instructions</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=recipe">Clear</a>}</div></form></details>}
|
||||
{!readOnlyMode&&<a class="archive-link" href="/app/archive/">Archive</a>}
|
||||
</nav>
|
||||
{query?<section class="workspace-search-results" aria-live="polite"><p><strong>{searchResults.length}</strong> {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}</p>{searchResults.length?<div>{searchResults.map((result)=><a href={result.href}><span class={`workspace-pill-icon ${result.kind}`}>{result.icon}</span><span><strong>{result.name}</strong><small>{result.detail}</small></span><em>{result.label}</em><b>›</b></a>)}</div>:<div class="empty-state">No items of the selected types match this search.</div>}</section>:<>
|
||||
{query?<section class="workspace-search-results" aria-live="polite"><p><strong>{searchResults.length}</strong> {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}</p>{searchRows.length?<EntityDirectory client:load rows={searchRows} emptyMessage="No items of the selected types match this search." readOnly={readOnlyMode}/>:<div class="empty-state">No items of the selected types match this search.</div>}</section>:<>
|
||||
{type==="ingredient"&&<EntityDirectory client:load rows={ingredientRows} entityType="ingredient" emptyMessage="No ingredients match these filters." readOnly={readOnlyMode}/>}
|
||||
{type==="recipe"&&<EntityDirectory client:load rows={recipeRows} entityType="recipe" emptyMessage="No recipes yet." readOnly={readOnlyMode}/>}
|
||||
{type==="book"&&<EntityDirectory client:load rows={bookRows} entityType="book" emptyMessage="No recipe books yet." readOnly={readOnlyMode}/>}
|
||||
{type==="purchase"&&<EntityDirectory client:load rows={purchaseRows} entityType="purchase" emptyMessage="No purchase items yet." readOnly={readOnlyMode}/>}
|
||||
{!type&&<p class="empty-state">Select a workspace or search to browse recipes, ingredients, and purchase items.</p>}
|
||||
</>}
|
||||
</section></BaseLayout>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,7 +4,7 @@ import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import { openDatabase, refreshSiteProjection } from "../../../../lib/database";
|
||||
import { readOnlyMode } from "../../../../lib/runtime";
|
||||
import { bestUsdaPortions, fetchUsdaFood, usdaNutrition } from "../../../../lib/usda";
|
||||
import { number } from "../../../../lib/format";
|
||||
import { number, titleCase } from "../../../../lib/format";
|
||||
import PurchaseItemForm from "../../../../components/PurchaseItemForm.astro";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
const id = Astro.params.id!;
|
||||
@@ -126,29 +126,333 @@ const prepDisplay = prep.map((row) => {
|
||||
return measure ? `${number(measure.quantity)} ${unitById.get(measure.unit_id)?.symbol ?? measure.unit_id}` : "—";
|
||||
};
|
||||
return {...row,weight:showMeasure("mass"),volume:showMeasure("volume"),each:showMeasure("count")};
|
||||
});
|
||||
});const RECIPE_TAB_ICONS = {
|
||||
overview: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z",
|
||||
costing: "M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4",
|
||||
equivalencies: "M19.4 3.3h-6.6v-.5c0-.4-.3-.7-.8-.7-.4 0-.8.3-.8.7v.5H4.6L0 14s.2 3.8 4.7 3.8S9.4 14 9.4 14L6.1 6.2h5.1v15.7h1.5V6.2h5.1L14.6 14s.2 3.8 4.7 3.8S24 14 24 14L19.4 3.3zM7.7 14H1.5l3.1-7.4L7.7 14zm8.5 0l3.1-7.4 3.1 7.4h-6.2z",
|
||||
nutrition: "M9.42859 2.37431L9.80926 2.82134L10.1899 2.37431C11.1674 1.22652 12.668 0.5 14.2234 0.5C16.9685 0.5 19.1185 2.64998 19.1185 5.3951C19.1185 7.0848 18.3631 8.65707 16.9325 10.4062C15.4961 12.1623 13.4317 14.0352 10.8957 16.3348L10.895 16.3354L9.80799 17.325L8.72319 16.345L8.72211 16.344L8.71119 16.3341C6.18062 14.0344 4.12043 12.1623 2.68618 10.4075C1.25541 8.6571 0.5 7.08481 0.5 5.3951C0.5 2.64998 2.64998 0.5 5.3951 0.5C6.95051 0.5 8.45117 1.22652 9.42859 2.37431ZM3.70568 10.127C5.0829 11.7363 7.04455 13.5134 9.36637 15.6157L9.45571 15.7051L9.80926 16.0586L10.1628 15.7051L10.2522 15.6157C12.574 13.5134 14.5356 11.7363 15.9128 10.127C17.287 8.52131 18.1567 6.99709 18.1567 5.3951C18.1567 3.1571 16.4614 1.46185 14.2234 1.46185C12.6415 1.46185 11.0895 2.39876 10.4049 3.77684H9.22149C8.52967 2.40009 6.97866 1.46185 5.3951 1.46185C3.1571 1.46185 1.46185 3.1571 1.46185 5.3951C1.46185 6.99709 2.3315 8.52131 3.70568 10.127Z"
|
||||
};
|
||||
const RECIPE_TAB_VIEWBOX = { overview: "0 0 24 24", costing: "0 0 24 24", equivalencies: "0 0 24 24", nutrition: "0 0 20 18" };
|
||||
const recipeTabIcon = (name: string) => `<span class="recipe-tab-icon"><svg viewBox="${RECIPE_TAB_VIEWBOX[name as keyof typeof RECIPE_TAB_VIEWBOX]}" aria-hidden="true"><path fill="currentColor" d="${RECIPE_TAB_ICONS[name as keyof typeof RECIPE_TAB_ICONS]}"/></svg></span>`;
|
||||
database.close();
|
||||
---
|
||||
<BaseLayout title={ingredient.name} immersive>
|
||||
<section class="entity-detail-shell">
|
||||
<DetailUtility section="Ingredients" sectionHref="/app/?type=ingredient" />
|
||||
<header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=ingredient">← Ingredients</a></p>{editing?<textarea class="editable-entity-title ingredient-title-editor" name="name" form="ingredient-identity-form" aria-label="Ingredient name" rows="1" required>{ingredient.name}</textarea>:<h1>{ingredient.name}</h1>}</div>{!readOnlyMode&&<div class="entity-header-actions">{editing?<button class="primary-command" id="ingredient-done" type="button">✓ Done</button>:<a class="edit-command" href={`/app/ingredients/${id}/?edit=1`}>✎ Edit</a>}{editing&&<details class="detail-actions-menu ingredient-actions-menu"><summary aria-label="Ingredient actions">⋮</summary><div><form method="post" data-confirm-message="Merge this ingredient? This changes every recipe that uses it."><label><span>Merge into</span><select name="target_id" required><option value="">Select canonical ingredient</option>{mergeCandidates.map(candidate=><option value={candidate.id}>{candidate.name}</option>)}</select></label><button name="intent" value="merge">Merge ingredient</button></form></div></details>}</div>}</header>
|
||||
{message&&<div class="success-notice entity-notice">{message}</div>}{error&&<div class="notice entity-notice">{error}</div>}
|
||||
<div class="entity-detail-grid">
|
||||
<main class="entity-primary">
|
||||
{editing&&<form method="post" id="ingredient-identity-form" class="ingredient-identity-form ingredient-identity-data"><input type="hidden" name="intent" value="identity"/><input type="hidden" name="status" value={ingredient.status}/><input type="hidden" id="ingredient-prep-json" name="prep_json" value="[]"/></form>}
|
||||
<section id="prep" class="entity-section ingredient-prep-section"><h2>Prep Actions</h2><p class="panel-intro">Any action taken on an ingredient that changes its yield or its weight-to-volume equivalency from the original raw state.</p>{editing?<><div class="prep-edit-wrap"><table class="prep-action-table prep-edit-table"><thead><tr><th>Prep Action</th><th>Yield %</th><th>Weight</th><th>Volume</th><th>Each</th><th></th></tr></thead><tbody id="ingredient-prep-rows" data-actions={JSON.stringify(actions)}>{prepDisplay.map(x=><tr class="prep-edit-row"><td><select class="prep-row-action" aria-label="Prep action">{actions.map(action=><option value={action.id} selected={action.id===x.action_id}>{action.name}</option>)}</select><input class="prep-row-notes" value={x.notes??""} placeholder="Optional notes" aria-label="Prep action notes"/></td><td><span class="percent-input"><input class="prep-row-yield" type="number" min="0.01" step="0.01" required value={x.yield_factor*100} aria-label="Yield percent"/><i>%</i></span></td>{[x.weight,x.volume,x.each].map(value=><td><button class="prep-equivalency-link" type="button" title="Edit this prep action's UoM equivalency">{value==="—"?"Set":value}</button></td>)}<td><button class="prep-row-remove" type="button" aria-label={`Remove ${x.name}`}>×</button></td></tr>)}</tbody></table></div><button id="add-prep-row" class="outlined-add-action" type="button">+ Add Prep Action</button><p class="field-help">Use 100% for no change, 80% for trim or cooking loss, or 250% when cooking produces 2.5 times the original weight. Select Weight, Volume, or Each to define its equivalency.</p></>:prep.length?<table class="prep-action-table"><thead><tr><th>Prep Action</th><th>Yield %</th><th>Weight</th><th>Volume</th><th>Each</th></tr></thead><tbody>{prepDisplay.map(x=><tr><td><strong>{x.name}</strong>{x.notes&&<small>{x.notes}</small>}</td><td>{number(x.yield_factor*100)}%</td><td>{x.weight}</td><td>{x.volume}</td><td>{x.each}</td></tr>)}</tbody></table>:<p class="empty-copy">No prep actions defined.</p>}</section>
|
||||
<section id="usage" class:list={["entity-section","additional-card",{"ingredient-additional-edit":editing}]}><h2>Additional Details</h2>{editing?<div class="ingredient-detail-fields"><label><span>Tags</span><input id="ingredient-tags" name="tags" form="ingredient-identity-form" value={ingredientTags.join(", ")} placeholder="Tag Name"/></label><label><span>Description</span><textarea name="description" form="ingredient-identity-form" rows="4" placeholder="Write your ingredient description">{ingredient.description??""}</textarea></label><label><span>Ingredient aliases</span><small>One alias per line</small><textarea name="aliases" form="ingredient-identity-form" rows="4">{aliases.map(x=>x.name).join("\n")}</textarea></label></div>:<><details open><summary>Recipes On <small>{usedIn.length}</small></summary>{usedIn.length?<ul>{usedIn.map(x=><li><a href={`/app/recipes/${x.id}/`}>{x.title}</a></li>)}</ul>:<p>This ingredient is not used by a recipe.</p>}</details>{ingredientTags.length>0&&<div class="ingredient-tag-view"><strong>Tags</strong><span>{ingredientTags.map(tag=><i>{tag}</i>)}</span></div>}{ingredient.description&&<div class="ingredient-description-view"><strong>Description</strong><p>{ingredient.description}</p></div>}<details><summary>Ingredient aliases <small>{aliases.length}</small></summary>{aliases.length?<ul>{aliases.map(x=><li>{x.name}</li>)}</ul>:<p>No aliases.</p>}</details></>}</section>
|
||||
<BaseLayout title={editing ? `Edit ${titleCase(ingredient.name)}` : titleCase(ingredient.name)} immersive>
|
||||
<section class="recipe-detail-shell recipe-read-shell ingredient-read-shell">
|
||||
<DetailUtility section="Ingredients" sectionHref="/app/?type=ingredient">
|
||||
<div class="entity-header-actions">
|
||||
{editing ? (
|
||||
<button class="primary-command" id="ingredient-done" type="button">✓ Done</button>
|
||||
) : !readOnlyMode && (
|
||||
<a class="edit-command" href={`/app/ingredients/${id}/?edit=1`}><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>Edit</a>
|
||||
)}
|
||||
{editing && (
|
||||
<details class="detail-actions-menu ingredient-actions-menu">
|
||||
<summary aria-label="Ingredient actions">⋮</summary>
|
||||
<div>
|
||||
<form method="post" data-confirm-message="Merge this ingredient? This changes every recipe that uses it.">
|
||||
<label><span>Merge into</span><select name="target_id" required><option value="">Select canonical ingredient</option>{mergeCandidates.map(candidate=><option value={candidate.id}>{candidate.name}</option>)}</select></label>
|
||||
<button name="intent" value="merge">Merge ingredient</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</DetailUtility>
|
||||
|
||||
<div class="recipe-read-left ingredient-read-left">
|
||||
<header class="entity-detail-header">
|
||||
<div>
|
||||
<p class="entity-breadcrumb"><a href="/app/?type=ingredient">← Ingredients</a></p>
|
||||
{editing ? (
|
||||
<textarea class="editable-entity-title ingredient-title-editor" name="name" form="ingredient-identity-form" aria-label="Ingredient name" rows="1" required>{ingredient.name}</textarea>
|
||||
) : (
|
||||
<h1>{titleCase(ingredient.name)}</h1>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{message && <div class="success-notice entity-notice">{message}</div>}
|
||||
{error && <div class="notice entity-notice">{error}</div>}
|
||||
|
||||
<main class="ingredient-main-content entity-tab-panel active" data-panel="overview">
|
||||
{editing && (
|
||||
<form method="post" id="ingredient-identity-form" class="ingredient-identity-form ingredient-identity-data">
|
||||
<input type="hidden" name="intent" value="identity"/>
|
||||
<input type="hidden" name="status" value={ingredient.status}/>
|
||||
<input type="hidden" id="ingredient-prep-json" name="prep_json" value="[]"/>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<section id="prep" class="entity-section ingredient-prep-section">
|
||||
<h2>Prep Actions</h2>
|
||||
<p class="panel-intro">Any action taken on an ingredient that changes its yield or its weight-to-volume equivalency from the original raw state.</p>
|
||||
{editing ? (
|
||||
<>
|
||||
<button id="add-prep-row" class="outlined-add-action" type="button">+ Add Prep Action</button>
|
||||
<div class="prep-edit-wrap">
|
||||
<table class="prep-action-table prep-edit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:35%;">Prep Action</th>
|
||||
<th style="width:18%;">Yield %</th>
|
||||
<th style="width:15%;">Weight</th>
|
||||
<th style="width:15%;">Volume</th>
|
||||
<th style="width:12%;">Each</th>
|
||||
<th style="width:5%;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ingredient-prep-rows" data-actions={JSON.stringify(actions)}>
|
||||
{prepDisplay.map(x => (
|
||||
<tr class="prep-edit-row">
|
||||
<td>
|
||||
<select class="prep-row-action" aria-label="Prep action">
|
||||
{actions.map(action => (
|
||||
<option value={action.id} selected={action.id === x.action_id}>{action.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<input class="prep-row-notes" value={x.notes ?? ""} placeholder="Optional notes" aria-label="Prep action notes"/>
|
||||
</td>
|
||||
<td>
|
||||
<span class="percent-input">
|
||||
<input class="prep-row-yield" type="number" min="0.01" step="0.01" required value={x.yield_factor * 100} aria-label="Yield percent"/>
|
||||
<i>%</i>
|
||||
</span>
|
||||
</td>
|
||||
{[x.weight, x.volume, x.each].map(value => (
|
||||
<td>
|
||||
<button class="prep-equivalency-link" type="button" title="Edit this prep action's UoM equivalency">{value === "—" ? "Set" : value}</button>
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
<button class="prep-row-remove" type="button" aria-label={`Remove ${x.name}`}>×</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="field-help">Use 100% for no change, 80% for trim or cooking loss, or 250% when cooking produces 2.5 times the original weight. Select Weight, Volume, or Each to define its equivalency.</p>
|
||||
</>
|
||||
) : prep.length ? (
|
||||
<table class="prep-action-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%;">Prep Action</th>
|
||||
<th style="width:20%;">Yield %</th>
|
||||
<th style="width:15%;">Weight</th>
|
||||
<th style="width:15%;">Volume</th>
|
||||
<th style="width:10%;">Each</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{prepDisplay.map(x => (
|
||||
<tr>
|
||||
<td><strong>{x.name}</strong>{x.notes && <small>{x.notes}</small>}</td>
|
||||
<td>{number(x.yield_factor * 100)}%</td>
|
||||
<td>{x.weight}</td>
|
||||
<td>{x.volume}</td>
|
||||
<td>{x.each}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p class="empty-copy">This ingredient currently has no prep actions. Edit ingredient to add prep actions.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section id="usage" class:list={["entity-section", "additional-card", { "ingredient-additional-edit": editing }]}>
|
||||
<h2>Additional Details</h2>
|
||||
{editing ? (
|
||||
<div class="ingredient-detail-fields">
|
||||
<label>
|
||||
<span>Tags</span>
|
||||
<input id="ingredient-tags" name="tags" form="ingredient-identity-form" value={ingredientTags.join(", ")} placeholder="Tag Name"/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<textarea name="description" form="ingredient-identity-form" rows="4" placeholder="Write your ingredient description">{ingredient.description ?? ""}</textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Ingredient aliases</span>
|
||||
<small>One alias per line</small>
|
||||
<textarea name="aliases" form="ingredient-identity-form" rows="4">{aliases.map(x => x.name).join("\n")}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<details open>
|
||||
<summary>Recipes On <small>{usedIn.length}</small></summary>
|
||||
{usedIn.length ? <ul>{usedIn.map(x => <li><a href={`/app/recipes/${x.id}/`}>{x.title}</a></li>)}</ul> : <p>This ingredient is not used by a recipe.</p>}
|
||||
</details>
|
||||
{ingredientTags.length > 0 && (
|
||||
<div class="ingredient-tag-view">
|
||||
<strong>Tags</strong>
|
||||
<span>{ingredientTags.map(tag => <i>{tag}</i>)}</span>
|
||||
</div>
|
||||
)}
|
||||
{ingredient.description && (
|
||||
<div class="ingredient-description-view">
|
||||
<strong>Description</strong>
|
||||
<p>{ingredient.description}</p>
|
||||
</div>
|
||||
)}
|
||||
<details>
|
||||
<summary>Ingredient aliases <small>{aliases.length}</small></summary>
|
||||
{aliases.length ? <ul>{aliases.map(x => <li>{x.name}</li>)}</ul> : <p>No aliases.</p>}
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
<aside class="entity-secondary">
|
||||
<nav class="entity-tabs" aria-label="Ingredient details"><button class="active" type="button" data-tab="costs">$ <span>Cost</span></button><button type="button" data-tab="equivalencies">⚖ <span>UoM Equivalency</span></button><button type="button" data-tab="nutrition">♡ <span>Nutrition</span></button></nav>
|
||||
<section id="costs" class="workspace-panel entity-tab-panel active" data-panel="costs"><h2>Ingredient Cost</h2><p class="panel-intro">Purchase packages, usable yield, and current prices.</p>{editing?<div class="ingredient-cost-edit">{purchases.map(x=><section class="ingredient-purchase-form"><header><h3>{x.name}</h3><form method="post" data-confirm-message="Remove this purchase item and its price history?"><input type="hidden" name="purchase_item_id" value={x.id}/><button name="intent" value="delete_purchase_item">Remove</button></form></header><PurchaseItemForm item={x} units={units}/></section>)}<details class="purchase-editor add-purchase" open={purchases.length===0}><summary>+ Add purchase item</summary><PurchaseItemForm units={units}/></details></div>:purchases.length?purchases.map(x=><div class="ingredient-cost-row"><span><strong>{x.name}</strong><small>{x.supplier_id||"No supplier"} · {x.status}</small></span><span><strong>{x.latest_price!=null?`${x.latest_currency} ${Number(x.latest_price).toFixed(2)}`:"No price"}</strong><small>{number(x.package_quantity)} {unitById.get(x.package_unit_id)?.symbol??x.package_unit_id}{x.units_per_case>1?` × ${x.units_per_case}`:""}</small></span></div>):<p class="empty-copy">No purchase cost has been entered.</p>}</section>
|
||||
<section id="equivalencies" class="workspace-panel entity-tab-panel" data-panel="equivalencies"><h2>UoM Equivalency</h2><p class="panel-intro">Define equivalent weight, volume, or count measurements for this ingredient.</p><div class="equivalency-list">{densities.map(x=><p><strong>{number(x.volume_quantity)} {unitById.get(x.volume_unit_id)?.symbol??x.volume_unit_id}</strong><span>=</span><strong>{number(x.mass_quantity)} {unitById.get(x.mass_unit_id)?.symbol??x.mass_unit_id}</strong><small>{x.state||"Density"} · sourced density</small></p>)}{conversionRows.map(x=><div class="equivalency-row"><p><strong>{number(x.from_quantity)} {unitById.get(x.from_unit_id)?.symbol??x.from_unit_id}</strong><span>=</span><strong>{number(x.to_quantity)} {unitById.get(x.to_unit_id)?.symbol??x.to_unit_id}</strong><small>{x.state||"Conversion"} · {x.isManual?"manual":x.source.title||"sourced"}</small></p>{editing&&x.isManual&&<div class="equivalency-actions"><button type="button" class="edit-equivalency" data-id={x.id} data-from-quantity={x.from_quantity} data-from-unit={x.from_unit_id} data-to-quantity={x.to_quantity} data-to-unit={x.to_unit_id} data-state={x.state??""}>Edit</button><form method="post" data-confirm-message="Remove this equivalency?"><input type="hidden" name="conversion_id" value={x.id}/><button name="intent" value="delete_conversion">Delete</button></form></div>}</div>)}</div>{!densities.length&&!conversions.length&&<p class="empty-copy">No equivalencies have been defined.</p>}{editing&&<details class="inline-editor equivalency-editor"><summary>+ Add Equivalency</summary><form method="post" class="inline-form"><input type="hidden" name="conversion_id" value=""/><label><span>From amount</span><input name="from_quantity" type="number" min="0.0001" step="any" value="1"/></label><label><span>From unit</span><select name="from_unit_id">{units.map(x=><option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label><i>=</i><label><span>To amount</span><input name="to_quantity" type="number" min="0.0001" step="any" required/></label><label><span>To unit</span><select name="to_unit_id">{units.map(x=><option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label><label><span>Preparation state</span><input name="state" placeholder="e.g. chopped"/></label><button name="intent" value="conversion">Add</button><button type="button" class="cancel-equivalency" hidden>Cancel</button></form></details>}</section>
|
||||
<section id="nutrition" class="workspace-panel entity-tab-panel" data-panel="nutrition"><h2>Nutrition</h2><p class="panel-intro">Nutrition values are sourced from the ingredient's mapped USDA FoodData Central record.</p>{editing&&<form method="post" class="usda-id-form"><label><span>USDA FoodData Central ID</span><input name="fdc_id" inputmode="numeric" pattern="[0-9]+" required value={usdaMapping?.source.external_id ?? ""}/></label><button name="intent" value="usda_mapping">Update from USDA</button>{usdaMapping?.sourceUrl&&<a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">Open USDA ↗</a>}</form>}{usdaMapping?<article class="ingredient-nutrition"><div class="nutrition-mapping-head"><div><strong>{usdaMapping.source.title}</strong><small>{usdaMapping.status} · USDA FoodData Central</small></div>{usdaMapping.sourceUrl&&<a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">View source ↗</a>}</div>{usdaMapping.nutrients.length?<dl>{usdaMapping.nutrients.map((entry:[string,number])=>{const[key,value]=entry;const[label,unit]=nutrientLabels[key]??[key.replaceAll("_"," "),""];return <div><dt>{label}</dt><dd>{number(value)} {unit}</dd></div>})}</dl>:<p class="notice">No reviewed nutrient values stored.</p>}<footer>{usdaMapping.source.external_id&&<span>Record {usdaMapping.source.external_id}</span>}{usdaMapping.source.retrieved_at&&<span>Retrieved {usdaMapping.source.retrieved_at}</span>}</footer></article>:<p class="empty-copy">No USDA record mapped.{editing&&" Enter an FDC ID above."}</p>}</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="recipe-read-right ingredient-read-right">
|
||||
<div class="recipe-workspace-tabs recipe-read-tabs entity-tabs" aria-label="Ingredient details">
|
||||
<button class="mobile-only-tab active" type="button" data-tab="overview"><Fragment set:html={recipeTabIcon("overview")}/><span class="recipe-tab-label">Details</span></button>
|
||||
<button type="button" data-tab="costs"><Fragment set:html={recipeTabIcon("costing")}/><span class="recipe-tab-label">Cost</span></button>
|
||||
<button type="button" data-tab="equivalencies"><Fragment set:html={recipeTabIcon("equivalencies")}/><span class="recipe-tab-label">UoM Equivalency</span></button>
|
||||
<button type="button" data-tab="nutrition"><Fragment set:html={recipeTabIcon("nutrition")}/><span class="recipe-tab-label">Nutrition</span></button>
|
||||
</div>
|
||||
|
||||
<div class="recipe-view-details ingredient-view-details">
|
||||
<section id="costs" class="workspace-panel entity-tab-panel active" data-panel="costs">
|
||||
<h2>Ingredient Cost</h2>
|
||||
<p class="panel-intro">Purchase packages, usable yield, and current prices.</p>
|
||||
{editing ? (
|
||||
<div class="ingredient-cost-edit">
|
||||
<details class="purchase-editor add-purchase" open={purchases.length === 0}>
|
||||
<summary>+ Add purchase item</summary>
|
||||
<PurchaseItemForm units={units}/>
|
||||
</details>
|
||||
{purchases.map(x => (
|
||||
<section class="ingredient-purchase-form">
|
||||
<header>
|
||||
<h3>{x.name}</h3>
|
||||
<form method="post" data-confirm-message="Remove this purchase item and its price history?">
|
||||
<input type="hidden" name="purchase_item_id" value={x.id}/>
|
||||
<button name="intent" value="delete_purchase_item">Remove</button>
|
||||
</form>
|
||||
</header>
|
||||
<PurchaseItemForm item={x} units={units}/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : purchases.length ? (
|
||||
purchases.map(x => (
|
||||
<div class="ingredient-cost-row">
|
||||
<span>
|
||||
<strong>{x.name}</strong>
|
||||
<small>{x.supplier_id || "No supplier"} · {x.status}</small>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{x.latest_price != null ? `${x.latest_currency} ${Number(x.latest_price).toFixed(2)}` : "No price"}</strong>
|
||||
<small>{number(x.package_quantity)} {unitById.get(x.package_unit_id)?.symbol ?? x.package_unit_id}{x.units_per_case > 1 ? ` × ${x.units_per_case}` : ""}</small>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p class="empty-copy">No purchase cost has been entered.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section id="equivalencies" class="workspace-panel entity-tab-panel" data-panel="equivalencies">
|
||||
<h2>U of M Equivalency</h2>
|
||||
<p class="panel-intro"><strong>XX Weight = XX Volume = XX Each</strong><br>You can define a custom ingredient conversion from weight to volume and to a pc/each of the ingredient.</p>
|
||||
<div class="equivalency-list">
|
||||
{densities.map(x => (
|
||||
<p>
|
||||
<strong>{number(x.volume_quantity)} {unitById.get(x.volume_unit_id)?.symbol ?? x.volume_unit_id}</strong>
|
||||
<span>=</span>
|
||||
<strong>{number(x.mass_quantity)} {unitById.get(x.mass_unit_id)?.symbol ?? x.mass_unit_id}</strong>
|
||||
<small>{x.state || "Density"} · sourced density</small>
|
||||
</p>
|
||||
))}
|
||||
{conversionRows.map(x => (
|
||||
<div class="equivalency-row">
|
||||
<p>
|
||||
<strong>{number(x.from_quantity)} {unitById.get(x.from_unit_id)?.symbol ?? x.from_unit_id}</strong>
|
||||
<span>=</span>
|
||||
<strong>{number(x.to_quantity)} {unitById.get(x.to_unit_id)?.symbol ?? x.to_unit_id}</strong>
|
||||
<small>{x.state || "Conversion"} · {x.isManual ? "manual" : x.source.title || "sourced"}</small>
|
||||
</p>
|
||||
{editing && x.isManual && (
|
||||
<div class="equivalency-actions">
|
||||
<button type="button" class="edit-equivalency" data-id={x.id} data-from-quantity={x.from_quantity} data-from-unit={x.from_unit_id} data-to-quantity={x.to_quantity} data-to-unit={x.to_unit_id} data-state={x.state ?? ""}>Edit</button>
|
||||
<form method="post" data-confirm-message="Remove this equivalency?">
|
||||
<input type="hidden" name="conversion_id" value={x.id}/>
|
||||
<button name="intent" value="delete_conversion">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!densities.length && !conversions.length && <p class="empty-copy">No equivalencies have been defined.</p>}
|
||||
{editing && (
|
||||
<details class="inline-editor equivalency-editor">
|
||||
<summary>+ Add Equivalency</summary>
|
||||
<form method="post" class="inline-form">
|
||||
<input type="hidden" name="conversion_id" value=""/>
|
||||
<label><span>From amount</span><input name="from_quantity" type="number" min="0.0001" step="any" value="1"/></label>
|
||||
<label><span>From unit</span><select name="from_unit_id">{units.map(x => <option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label>
|
||||
<i>=</i>
|
||||
<label><span>To amount</span><input name="to_quantity" type="number" min="0.0001" step="any" required/></label>
|
||||
<label><span>To unit</span><select name="to_unit_id">{units.map(x => <option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label>
|
||||
<label><span>Preparation state</span><input name="state" placeholder="e.g. chopped"/></label>
|
||||
<button name="intent" value="conversion">Add</button>
|
||||
<button type="button" class="cancel-equivalency" hidden>Cancel</button>
|
||||
</form>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section id="nutrition" class="workspace-panel entity-tab-panel" data-panel="nutrition">
|
||||
<h2>Nutrition</h2>
|
||||
<p class="panel-intro">Nutrition values are sourced from the ingredient's mapped USDA FoodData Central record.</p>
|
||||
{editing && (
|
||||
<form method="post" class="usda-id-form">
|
||||
<label>
|
||||
<span>USDA FoodData Central ID</span>
|
||||
<input name="fdc_id" inputmode="numeric" pattern="[0-9]+" required value={usdaMapping?.source.external_id ?? ""}/>
|
||||
</label>
|
||||
<button name="intent" value="usda_mapping">Update from USDA</button>
|
||||
{usdaMapping?.sourceUrl && <a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">Open USDA ↗</a>}
|
||||
</form>
|
||||
)}
|
||||
{usdaMapping ? (
|
||||
<article class="ingredient-nutrition">
|
||||
<div class="nutrition-mapping-head">
|
||||
<div>
|
||||
<strong>{usdaMapping.source.title}</strong>
|
||||
<small>{usdaMapping.status} · USDA FoodData Central</small>
|
||||
</div>
|
||||
{usdaMapping.sourceUrl && <a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">View source ↗</a>}
|
||||
</div>
|
||||
{usdaMapping.nutrients.length ? (
|
||||
<dl>
|
||||
{usdaMapping.nutrients.map((entry: [string, number]) => {
|
||||
const [key, value] = entry;
|
||||
const [label, unit] = nutrientLabels[key] ?? [key.replaceAll("_", " "), ""];
|
||||
return <div><dt>{label}</dt><dd>{number(value)} {unit}</dd></div>;
|
||||
})}
|
||||
</dl>
|
||||
) : (
|
||||
<p class="notice">No reviewed nutrient values stored.</p>
|
||||
)}
|
||||
<footer>
|
||||
{usdaMapping.source.external_id && <span>Record {usdaMapping.source.external_id}</span>}
|
||||
{usdaMapping.source.retrieved_at && <span>Retrieved {usdaMapping.source.retrieved_at}</span>}
|
||||
</footer>
|
||||
</article>
|
||||
) : (
|
||||
<p class="empty-copy">No USDA record mapped.{editing && " Enter an FDC ID above."}</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{editing&&<script is:inline>
|
||||
{editing && <script is:inline>
|
||||
const ingredientForm=document.querySelector('#ingredient-identity-form'),doneButton=document.querySelector('#ingredient-done'),prepRows=document.querySelector('#ingredient-prep-rows'),prepJson=document.querySelector('#ingredient-prep-json');
|
||||
let ingredientDirty=false,ingredientSubmitting=false;
|
||||
const setIngredientDirty=(value=true)=>{ingredientDirty=value;doneButton?.classList.toggle('dirty',value)};
|
||||
@@ -185,7 +489,25 @@ database.close();
|
||||
const rows=[...prepRows.querySelectorAll('.prep-edit-row')],values=rows.map(row=>({action_id:row.querySelector('.prep-row-action').value,yield_percent:Number(row.querySelector('.prep-row-yield').value),notes:row.querySelector('.prep-row-notes').value.trim()})),ids=values.map(row=>row.action_id);
|
||||
if(new Set(ids).size!==ids.length){alert('Each prep action can only appear once.');return}if(values.some(row=>!Number.isFinite(row.yield_percent)||row.yield_percent<=0)){alert('Each prep yield must be greater than 0%.');return}prepJson.value=JSON.stringify(values);if(!ingredientForm.reportValidity())return;doneButton.disabled=true;doneButton.textContent='Saving…';ingredientForm.requestSubmit();
|
||||
});
|
||||
window.addEventListener('beforeunload',event=>{if(ingredientDirty&&!ingredientSubmitting)event.preventDefault()});
|
||||
</script>}
|
||||
<script is:inline>document.querySelectorAll('form[data-confirm-message]').forEach(form=>form.addEventListener('submit',event=>{if(!confirm(form.dataset.confirmMessage))event.preventDefault()}));document.querySelectorAll('.entity-tabs button').forEach((button)=>button.addEventListener('click',()=>{document.querySelectorAll('.entity-tabs button').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.entity-tab-panel').forEach(x=>x.classList.remove('active'));button.classList.add('active');document.querySelector(`[data-panel="${button.dataset.tab}"]`)?.classList.add('active');}));const hash=location.hash.slice(1);if(hash)document.querySelector(`[data-tab="${hash}"]`)?.click();</script>
|
||||
window.addEventListener('beforeunload',event=>{if(ingredientDirty&&!ingredientSubmitting)event.preventDefault()});
|
||||
</script>
|
||||
}
|
||||
<script is:inline>
|
||||
document.querySelectorAll('form[data-confirm-message]').forEach(form=>form.addEventListener('submit',event=>{if(!confirm(form.dataset.confirmMessage))event.preventDefault()}));
|
||||
function switchIngredientTab(tabName) {
|
||||
document.querySelectorAll('.entity-tabs button').forEach(x=>x.classList.toggle('active', x.dataset.tab === tabName));
|
||||
document.querySelectorAll('.entity-tab-panel').forEach(x=>x.classList.toggle('active', x.dataset.panel === tabName));
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
}
|
||||
document.querySelectorAll('.entity-tabs button').forEach((button)=>button.addEventListener('click',()=>{
|
||||
switchIngredientTab(button.dataset.tab);
|
||||
}));
|
||||
const hash=location.hash.slice(1);
|
||||
if(hash) {
|
||||
const target = document.querySelector(`[data-tab="${hash}"]`);
|
||||
if (target) target.click();
|
||||
} else if (window.innerWidth <= 900) {
|
||||
switchIngredientTab('overview');
|
||||
}
|
||||
</script>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import { openDatabase } from "../../../../lib/database";
|
||||
import {
|
||||
getInventoryCountDetail,
|
||||
saveInventoryCountItems,
|
||||
} from "../../../../lib/repository/inventory-repository";
|
||||
import { readOnlyMode } from "../../../../lib/runtime";
|
||||
|
||||
const id = Astro.params.id!;
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Astro.redirect("/app/?error=database-missing", 303);
|
||||
|
||||
let error = "";
|
||||
if (Astro.request.method === "POST") {
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
const intent = String(form.get("intent") ?? "save");
|
||||
|
||||
if (intent === "save" || intent === "complete" || intent === "reopen") {
|
||||
const ingredientIds = form.getAll("ingredient_id").map(String);
|
||||
const locationIds = form.getAll("location_id").map((v) => String(v) || null);
|
||||
const quantities = form.getAll("quantity").map((v) => Number(v) || 0);
|
||||
const unitIds = form.getAll("unit_id").map(String);
|
||||
|
||||
const items = ingredientIds.map((ingId, idx) => ({
|
||||
ingredient_id: ingId,
|
||||
location_id: locationIds[idx] ?? null,
|
||||
quantity: quantities[idx] ?? 0,
|
||||
unit_id: unitIds[idx] ?? "gram",
|
||||
}));
|
||||
|
||||
const newStatus =
|
||||
intent === "complete"
|
||||
? "completed"
|
||||
: intent === "reopen"
|
||||
? "open"
|
||||
: undefined;
|
||||
|
||||
saveInventoryCountItems(database, id, items, newStatus);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/inventory/${id}/`, 303);
|
||||
}
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "Unable to save count sheet.";
|
||||
}
|
||||
}
|
||||
|
||||
const count = getInventoryCountDetail(database, id);
|
||||
if (!count) {
|
||||
database.close();
|
||||
return new Response("Inventory count session not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch all available units and active ingredients for the quick-add selector
|
||||
const allUnits = database.prepare("SELECT id, name, symbol FROM units ORDER BY name").all() as Array<{ id: string; name: string; symbol: string }>;
|
||||
const activeIngredients = database.prepare("SELECT id, name FROM ingredients WHERE status='active' AND deleted_at IS NULL ORDER BY name").all() as Array<{ id: string; name: string }>;
|
||||
|
||||
database.close();
|
||||
|
||||
const selectedLocation = Astro.url.searchParams.get("loc") ?? "all";
|
||||
const filteredItems = count.items.filter((item) => {
|
||||
if (selectedLocation === "all") return true;
|
||||
if (selectedLocation === "unassigned") return !item.location_id;
|
||||
return item.location_id === selectedLocation;
|
||||
});
|
||||
|
||||
const isCompleted = count.status === "completed";
|
||||
---
|
||||
|
||||
<BaseLayout title={count.title} immersive>
|
||||
<div class="count-sheet-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="count-sheet-workspace">
|
||||
{error && <div class="notice count-notice">{error}</div>}
|
||||
|
||||
<header class="count-sheet-header">
|
||||
<div class="count-sheet-header-left">
|
||||
<nav class="count-breadcrumbs">
|
||||
<a href="/app/inventory/">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>Inventory counts</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="count-title-row">
|
||||
<h1>{count.title}</h1>
|
||||
<span class={`status-badge ${count.status}`}>
|
||||
{isCompleted ? "Completed" : "Open Draft"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="count-sheet-meta">
|
||||
<span>Count Date: <strong>{count.counted_at}</strong></span>
|
||||
{count.notes && <span>· {count.notes}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="count-sheet-header-right">
|
||||
<div class="total-valuation-pill">
|
||||
<span class="val-label">Total On-Hand Valuation</span>
|
||||
<strong class="val-amount font-mono" id="header-total-value">
|
||||
${count.total_value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
{!readOnlyMode && (
|
||||
<div class="count-header-actions">
|
||||
{isCompleted ? (
|
||||
<form method="post" class="inline-action-form">
|
||||
<input type="hidden" name="intent" value="reopen" />
|
||||
<button type="submit" class="secondary-btn">Reopen Count</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" class="secondary-btn" id="save-draft-trigger">
|
||||
Save Draft
|
||||
</button>
|
||||
<button type="button" class="complete-btn" id="complete-count-trigger">
|
||||
✓ Complete Count
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Location Filter Tabs -->
|
||||
<nav class="location-tabs" aria-label="Filter count sheet by location">
|
||||
<a
|
||||
href={`/app/inventory/${id}/`}
|
||||
class:list={["loc-tab", { active: selectedLocation === "all" }]}
|
||||
>
|
||||
<span>All Locations</span>
|
||||
<small>{count.items.length}</small>
|
||||
</a>
|
||||
{count.locations.map((loc) => {
|
||||
const locItemCount = count.items.filter((i) => i.location_id === loc.id).length;
|
||||
return (
|
||||
<a
|
||||
href={`/app/inventory/${id}/?loc=${loc.id}`}
|
||||
class:list={["loc-tab", { active: selectedLocation === loc.id }]}
|
||||
>
|
||||
<span>{loc.name}</span>
|
||||
<small>{locItemCount}</small>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<!-- Count Sheet Table Form -->
|
||||
<form method="post" id="count-sheet-form">
|
||||
<input type="hidden" name="intent" id="form-intent" value="save" />
|
||||
|
||||
<div class="count-table-container">
|
||||
<table class="count-items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-ing">Ingredient</th>
|
||||
<th class="col-loc">Location</th>
|
||||
<th class="col-qty">Quantity</th>
|
||||
<th class="col-unit">Unit</th>
|
||||
<th class="col-cost">Unit Cost</th>
|
||||
<th class="col-ext">Extended Value</th>
|
||||
{!isCompleted && !readOnlyMode && <th class="col-del"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="count-table-body">
|
||||
{filteredItems.map((item) => (
|
||||
<tr class="count-item-row" data-unit-cost={item.unit_cost ?? 0}>
|
||||
<td class="col-ing">
|
||||
<input type="hidden" name="ingredient_id" value={item.ingredient_id} />
|
||||
<strong>{item.ingredient_name}</strong>
|
||||
</td>
|
||||
<td class="col-loc">
|
||||
{isCompleted || readOnlyMode ? (
|
||||
<span>{item.location_name ?? "Unassigned"}</span>
|
||||
) : (
|
||||
<select name="location_id" class="table-loc-select">
|
||||
<option value="">(Unassigned)</option>
|
||||
{count.locations.map((loc) => (
|
||||
<option value={loc.id} selected={loc.id === item.location_id}>
|
||||
{loc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td class="col-qty">
|
||||
{isCompleted || readOnlyMode ? (
|
||||
<span class="font-mono">{item.quantity}</span>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
name="quantity"
|
||||
min="0"
|
||||
step="any"
|
||||
value={item.quantity}
|
||||
class="qty-input font-mono"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td class="col-unit">
|
||||
{isCompleted || readOnlyMode ? (
|
||||
<span>{item.unit_symbol}</span>
|
||||
) : (
|
||||
<select name="unit_id" class="table-unit-select">
|
||||
{allUnits.map((u) => (
|
||||
<option value={u.id} selected={u.id === item.unit_id}>
|
||||
{u.symbol || u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td class="col-cost font-mono">
|
||||
{item.unit_cost != null ? (
|
||||
`$${item.unit_cost.toFixed(4)}`
|
||||
) : (
|
||||
<span class="unpriced">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="col-ext font-mono row-extended-value">
|
||||
${(item.extended_cost ?? 0).toFixed(2)}
|
||||
</td>
|
||||
{!isCompleted && !readOnlyMode && (
|
||||
<td class="col-del">
|
||||
<button
|
||||
type="button"
|
||||
class="row-delete-btn"
|
||||
title="Remove from count"
|
||||
onclick="this.closest('tr').remove(); updateLiveTotals();"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filteredItems.length === 0 && (
|
||||
<div class="empty-location-notice">
|
||||
<p>No items assigned to this storage location in this count session.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCompleted && !readOnlyMode && (
|
||||
<div class="add-item-bar">
|
||||
<select id="quick-add-select" class="quick-add-dropdown">
|
||||
<option value="">+ Add item to count sheet…</option>
|
||||
{activeIngredients
|
||||
.filter((ing) => !count.items.some((i) => i.ingredient_id === ing.id))
|
||||
.map((ing) => (
|
||||
<option value={ing.id}>{ing.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" id="quick-add-btn" class="quick-add-btn">
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script is:inline>
|
||||
function updateLiveTotals() {
|
||||
let total = 0;
|
||||
document.querySelectorAll(".count-item-row").forEach((row) => {
|
||||
const unitCost = parseFloat(row.getAttribute("data-unit-cost") || "0");
|
||||
const qtyInput = row.querySelector(".qty-input");
|
||||
const qty = qtyInput ? parseFloat(qtyInput.value || "0") : 0;
|
||||
const ext = unitCost * qty;
|
||||
const extEl = row.querySelector(".row-extended-value");
|
||||
if (extEl) extEl.textContent = `$${ext.toFixed(2)}`;
|
||||
total += ext;
|
||||
});
|
||||
const headerEl = document.getElementById("header-total-value");
|
||||
if (headerEl) headerEl.textContent = `$${total.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll(".qty-input").forEach((input) => {
|
||||
input.addEventListener("input", updateLiveTotals);
|
||||
});
|
||||
|
||||
const saveTrigger = document.getElementById("save-draft-trigger");
|
||||
if (saveTrigger) {
|
||||
saveTrigger.addEventListener("click", () => {
|
||||
const form = document.getElementById("count-sheet-form");
|
||||
const intent = document.getElementById("form-intent");
|
||||
if (form && intent) {
|
||||
intent.value = "save";
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const completeTrigger = document.getElementById("complete-count-trigger");
|
||||
if (completeTrigger) {
|
||||
completeTrigger.addEventListener("click", () => {
|
||||
if (confirm("Complete and finalize this inventory count? On-hand valuations will be finalized.")) {
|
||||
const form = document.getElementById("count-sheet-form");
|
||||
const intent = document.getElementById("form-intent");
|
||||
if (form && intent) {
|
||||
intent.value = "complete";
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const addBtn = document.getElementById("quick-add-btn");
|
||||
const selectEl = document.getElementById("quick-add-select");
|
||||
if (addBtn && selectEl) {
|
||||
addBtn.addEventListener("click", () => {
|
||||
const selectedId = selectEl.value;
|
||||
const selectedName = selectEl.options[selectEl.selectedIndex]?.text;
|
||||
if (!selectedId) return;
|
||||
|
||||
const tbody = document.getElementById("count-table-body");
|
||||
if (tbody) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "count-item-row";
|
||||
tr.setAttribute("data-unit-cost", "0");
|
||||
tr.innerHTML = `
|
||||
<td class="col-ing">
|
||||
<input type="hidden" name="ingredient_id" value="${selectedId}" />
|
||||
<strong>${selectedName}</strong>
|
||||
</td>
|
||||
<td class="col-loc">
|
||||
<select name="location_id" class="table-loc-select">
|
||||
<option value="">(Unassigned)</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="col-qty">
|
||||
<input type="number" name="quantity" min="0" step="any" value="0" class="qty-input font-mono" required />
|
||||
</td>
|
||||
<td class="col-unit">
|
||||
<select name="unit_id" class="table-unit-select">
|
||||
<option value="gram">g</option>
|
||||
<option value="each">ea</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="col-cost font-mono">—</td>
|
||||
<td class="col-ext font-mono row-extended-value">$0.00</td>
|
||||
<td class="col-del">
|
||||
<button type="button" class="row-delete-btn" onclick="this.closest('tr').remove(); updateLiveTotals();">×</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
tr.querySelector(".qty-input")?.addEventListener("input", updateLiveTotals);
|
||||
selectEl.remove(selectEl.selectedIndex);
|
||||
selectEl.value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.count-sheet-workspace {
|
||||
padding: 72px max(24px, calc((100vw - 1160px) / 2)) 80px;
|
||||
}
|
||||
.count-sheet-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.count-breadcrumbs a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.count-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.count-title-row h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
margin: 0;
|
||||
}
|
||||
.count-sheet-meta {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.count-sheet-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.total-valuation-pill {
|
||||
background: #f1f5fe;
|
||||
border: 1px solid #dbe4ff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.val-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.val-amount {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--blue);
|
||||
}
|
||||
.count-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.secondary-btn {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #d0d6e4;
|
||||
background: white;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #050841;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.secondary-btn:hover {
|
||||
border-color: #050841;
|
||||
background: #f8faff;
|
||||
}
|
||||
.complete-btn {
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
background: #3d5df6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.complete-btn:hover {
|
||||
background: #2b4be0;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.status-badge.open {
|
||||
background: #fff8e6;
|
||||
color: #b25e00;
|
||||
border: 1px solid #ffd599;
|
||||
}
|
||||
.status-badge.completed {
|
||||
background: #e6f9f3;
|
||||
color: #0d8262;
|
||||
border: 1px solid #a3ebd4;
|
||||
}
|
||||
|
||||
/* Location tabs */
|
||||
.location-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.loc-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: white;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.loc-tab.active {
|
||||
background: #050841;
|
||||
color: white;
|
||||
border-color: #050841;
|
||||
}
|
||||
.loc-tab small {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.loc-tab.active small {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.count-table-container {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.count-items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.count-items-table th,
|
||||
.count-items-table td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
}
|
||||
.count-items-table th {
|
||||
background: #fbfbfb;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.count-items-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.col-ing {
|
||||
width: 30%;
|
||||
}
|
||||
.col-loc {
|
||||
width: 20%;
|
||||
}
|
||||
.col-qty {
|
||||
width: 15%;
|
||||
}
|
||||
.col-unit {
|
||||
width: 12%;
|
||||
}
|
||||
.col-cost {
|
||||
width: 11%;
|
||||
text-align: right;
|
||||
}
|
||||
.col-ext {
|
||||
width: 12%;
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
}
|
||||
.col-del {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.qty-input {
|
||||
width: 90px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.table-loc-select,
|
||||
.table-unit-select {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.row-delete-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 18px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.row-delete-btn:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
.unpriced {
|
||||
color: var(--muted);
|
||||
}
|
||||
.empty-location-notice {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Add item bar */
|
||||
.add-item-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.quick-add-dropdown {
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background: white;
|
||||
}
|
||||
.quick-add-btn {
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
background: white;
|
||||
border: 1px solid #050841;
|
||||
border-radius: 999px;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #050841;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.quick-add-btn:hover {
|
||||
color: #3d5df6;
|
||||
border-color: #3d5df6;
|
||||
background: #f1f5fe;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.count-sheet-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.count-sheet-header-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,519 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import { openDatabase } from "../../../../lib/database";
|
||||
import {
|
||||
createInventoryCount,
|
||||
getInventoryCounts,
|
||||
getInventoryLocations,
|
||||
} from "../../../../lib/repository/inventory-repository";
|
||||
import { readOnlyMode } from "../../../../lib/runtime";
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Astro.redirect("/app/?error=database-missing", 303);
|
||||
|
||||
let error = "";
|
||||
if (Astro.request.method === "POST") {
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
const intent = String(form.get("intent") ?? "create");
|
||||
|
||||
if (intent === "create") {
|
||||
const title = String(form.get("title") ?? "").trim();
|
||||
const counted_at = String(form.get("counted_at") ?? "").trim();
|
||||
const notes = String(form.get("notes") ?? "").trim();
|
||||
const prepopulate = form.get("prepopulate") === "1";
|
||||
|
||||
if (!title) throw new Error("Title is required.");
|
||||
if (!counted_at) throw new Error("Count date is required.");
|
||||
|
||||
const createdId = createInventoryCount(database, {
|
||||
title,
|
||||
counted_at,
|
||||
notes,
|
||||
prepopulate,
|
||||
});
|
||||
|
||||
database.close();
|
||||
return Astro.redirect(`/app/inventory/${createdId}/`, 303);
|
||||
}
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "Unable to create inventory count.";
|
||||
}
|
||||
}
|
||||
|
||||
const counts = getInventoryCounts(database);
|
||||
const locations = getInventoryLocations(database);
|
||||
const totalInventoryValue = counts
|
||||
.filter((c) => c.status === "completed")
|
||||
.reduce((sum, c) => sum + c.total_value, 0);
|
||||
|
||||
database.close();
|
||||
---
|
||||
|
||||
<BaseLayout title="Inventory" immersive>
|
||||
<div class="inventory-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="inventory-workspace">
|
||||
{error && <div class="notice inventory-notice">{error}</div>}
|
||||
|
||||
<header class="inventory-header">
|
||||
<div class="inventory-header-left">
|
||||
<nav class="inventory-breadcrumbs">
|
||||
<a href="/app/">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>All items</span>
|
||||
</a>
|
||||
</nav>
|
||||
<h1>Inventory Count Sessions</h1>
|
||||
<p class="inventory-subtitle">
|
||||
Sheet-to-shelf on-hand stock counts, valuations, and storage locations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!readOnlyMode && (
|
||||
<div class="inventory-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="primary-count-btn"
|
||||
onclick="document.getElementById('new-count-dialog').showModal()"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z" />
|
||||
</svg>
|
||||
<span>New Count Session</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div class="inventory-summary-cards">
|
||||
<article class="inv-stat-card">
|
||||
<span class="inv-stat-label">Total Count Sessions</span>
|
||||
<strong class="inv-stat-value">{counts.length}</strong>
|
||||
</article>
|
||||
<article class="inv-stat-card">
|
||||
<span class="inv-stat-label">Storage Locations</span>
|
||||
<strong class="inv-stat-value">{locations.length}</strong>
|
||||
</article>
|
||||
<article class="inv-stat-card highlight">
|
||||
<span class="inv-stat-label">Total Completed Valuation</span>
|
||||
<strong class="inv-stat-value">
|
||||
${totalInventoryValue.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{counts.length > 0 ? (
|
||||
<div class="inventory-table">
|
||||
<div class="inventory-table-head">
|
||||
<span class="head-col date">Count Date</span>
|
||||
<span class="head-col title">Session Title</span>
|
||||
<span class="head-col status">Status</span>
|
||||
<span class="head-col items">Items Counted</span>
|
||||
<span class="head-col value">Total Value</span>
|
||||
<span class="head-col actions"></span>
|
||||
</div>
|
||||
|
||||
<div class="inventory-table-body">
|
||||
{counts.map((count) => (
|
||||
<a href={`/app/inventory/${count.id}/`} class="inventory-table-row">
|
||||
<span class="col date">{count.counted_at}</span>
|
||||
<span class="col title">
|
||||
<strong>{count.title}</strong>
|
||||
{count.notes && <small>{count.notes}</small>}
|
||||
</span>
|
||||
<span class="col status">
|
||||
<span class={`status-badge ${count.status}`}>
|
||||
{count.status === "completed" ? "Completed" : "Open Draft"}
|
||||
</span>
|
||||
</span>
|
||||
<span class="col items">{count.item_count} items</span>
|
||||
<span class="col value font-mono">
|
||||
${count.total_value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span class="col actions">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty-inventory-state">
|
||||
<div class="empty-inv-icon">
|
||||
<svg viewBox="0 0 24 24" width="36" height="36" fill="currentColor">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 10h2v7H7zm4-3h2v10h-2zm4 6h2v4h-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>No inventory counts recorded yet</h2>
|
||||
<p>Create your first location-specific count session to begin tracking on-hand stock and valuations.</p>
|
||||
{!readOnlyMode && (
|
||||
<button
|
||||
type="button"
|
||||
class="primary-count-btn"
|
||||
onclick="document.getElementById('new-count-dialog').showModal()"
|
||||
>
|
||||
Start First Count Session
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- New Count Session Modal Dialog -->
|
||||
<dialog id="new-count-dialog" class="count-modal-dialog">
|
||||
<form method="post">
|
||||
<input type="hidden" name="intent" value="create" />
|
||||
<div class="dialog-header">
|
||||
<h2>New Inventory Count Session</h2>
|
||||
<button type="button" class="close-btn" onclick="this.closest('dialog').close()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
<label class="form-field">
|
||||
<span>Session Title *</span>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
required
|
||||
placeholder="e.g. Month-End Count - August 2026"
|
||||
value={`Count - ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span>Count Date *</span>
|
||||
<input
|
||||
type="date"
|
||||
name="counted_at"
|
||||
required
|
||||
value={new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span>Notes / Shift (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
name="notes"
|
||||
placeholder="e.g. Sunday night post-service count"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="checkbox-field">
|
||||
<input type="checkbox" name="prepopulate" value="1" checked />
|
||||
<span>Pre-populate all active catalog ingredients with 0 count</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="cancel-btn" onclick="this.closest('dialog').close()">Cancel</button>
|
||||
<button type="submit" class="submit-btn">Create Count Sheet</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.inventory-workspace {
|
||||
padding: 72px max(24px, calc((100vw - 1160px) / 2)) 80px;
|
||||
}
|
||||
.inventory-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.inventory-breadcrumbs a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.inventory-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.inventory-subtitle {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.primary-count-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
background: #3d5df6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.primary-count-btn:hover {
|
||||
background: #2b4be0;
|
||||
}
|
||||
.inventory-summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.inv-stat-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.inv-stat-card.highlight {
|
||||
background: #f8faff;
|
||||
border-color: #dbe4ff;
|
||||
}
|
||||
.inv-stat-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.inv-stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
.inventory-table {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.inventory-table-head,
|
||||
.inventory-table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px minmax(180px, 1fr) 130px 140px 140px 40px;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
.inventory-table-head {
|
||||
background: #fbfbfb;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.inventory-table-row {
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.inventory-table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.inventory-table-row:hover {
|
||||
background: #f8faff;
|
||||
}
|
||||
.inventory-table-row .title strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
}
|
||||
.inventory-table-row .title small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.status-badge.open {
|
||||
background: #fff8e6;
|
||||
color: #b25e00;
|
||||
border: 1px solid #ffd599;
|
||||
}
|
||||
.status-badge.completed {
|
||||
background: #e6f9f3;
|
||||
color: #0d8262;
|
||||
border: 1px solid #a3ebd4;
|
||||
}
|
||||
.col.value {
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
.col.actions {
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.empty-inventory-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.empty-inv-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 16px;
|
||||
background: #f1f5fe;
|
||||
color: var(--blue);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.empty-inventory-state h2 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.empty-inventory-state p {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
max-width: 420px;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.count-modal-dialog {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
width: min(500px, calc(100vw - 32px));
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.count-modal-dialog::backdrop {
|
||||
background: rgba(5, 8, 65, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.dialog-header h2 {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
.close-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-field input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.checkbox-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
.dialog-footer {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
background: #fbfbfb;
|
||||
}
|
||||
.cancel-btn {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #8b93a7;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
.cancel-btn:hover {
|
||||
color: #050841;
|
||||
}
|
||||
.submit-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
background: #3d5df6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.submit-btn:hover {
|
||||
background: #2b4be0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.inventory-summary-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.inventory-table-head {
|
||||
display: none;
|
||||
}
|
||||
.inventory-table-row {
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
}
|
||||
.inventory-table-row .col.items,
|
||||
.inventory-table-row .col.date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,235 @@
|
||||
---
|
||||
export const prerender=false;
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import {readOnlyMode} from "../../../../lib/runtime";
|
||||
import {openDatabase,refreshSiteProjection} from "../../../../lib/database";
|
||||
const id=Astro.params.id!,editing=!readOnlyMode&&Astro.url.searchParams.get("edit")==="1";
|
||||
const database=openDatabase({readOnly:false});if(!database)return Astro.redirect("/app/?error=database-missing",303);
|
||||
let error="";
|
||||
if(Astro.request.method==="POST")try{const form=await Astro.request.formData(),intent=String(form.get("intent"));
|
||||
if(intent==="details"){const name=String(form.get("name")??"").trim();if(!name)throw new Error("Name is required.");database.prepare("UPDATE collections SET name=?,description=? WHERE id=?").run(name,String(form.get("description")??"").trim()||null,id);}
|
||||
if(intent==="membership"){const selected=new Set(form.getAll("recipe_id").map(String));database.exec("BEGIN IMMEDIATE");try{database.prepare("DELETE FROM collection_recipes WHERE collection_id=?").run(id);const insert=database.prepare("INSERT INTO collection_recipes(collection_id,recipe_id,position) VALUES (?,?,?)");[...selected].forEach((recipeId,index)=>insert.run(id,recipeId,index+1));database.exec("COMMIT");}catch(cause){database.exec("ROLLBACK");throw cause;}}
|
||||
refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipe-books/${id}/`,303);
|
||||
}catch(cause){error=cause instanceof Error?cause.message:"Unable to save recipe book.";}
|
||||
const book=database.prepare("SELECT * FROM collections WHERE id=? AND deleted_at IS NULL").get(id) as any;if(!book){database.close();return new Response("Recipe book not found",{status:404});}
|
||||
const recipes=database.prepare("SELECT r.id,r.title,cr.position,cr.recipe_id IS NOT NULL included FROM recipes r LEFT JOIN collection_recipes cr ON cr.recipe_id=r.id AND cr.collection_id=? WHERE r.deleted_at IS NULL ORDER BY coalesce(cr.position,999999),r.title").all(id) as any[];
|
||||
import { readOnlyMode } from "../../../../lib/runtime";
|
||||
import { openDatabase, refreshSiteProjection } from "../../../../lib/database";
|
||||
|
||||
const id = Astro.params.id!;
|
||||
const editing = !readOnlyMode && Astro.url.searchParams.get("edit") === "1";
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Astro.redirect("/app/?error=database-missing", 303);
|
||||
|
||||
let error = "";
|
||||
if (Astro.request.method === "POST") {
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
const intent = String(form.get("intent") ?? "save");
|
||||
|
||||
if (intent === "save" || intent === "details") {
|
||||
const name = String(form.get("name") ?? "").trim();
|
||||
const description = String(form.get("description") ?? "").trim() || null;
|
||||
if (!name) throw new Error("Name is required.");
|
||||
database.prepare("UPDATE collections SET name=?, description=? WHERE id=?").run(name, description, id);
|
||||
}
|
||||
|
||||
if (intent === "save" || intent === "membership") {
|
||||
const selected = new Set(form.getAll("recipe_id").map(String));
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
database.prepare("DELETE FROM collection_recipes WHERE collection_id=?").run(id);
|
||||
const insert = database.prepare("INSERT INTO collection_recipes(collection_id, recipe_id, position) VALUES (?, ?, ?)");
|
||||
[...selected].forEach((recipeId, index) => insert.run(id, recipeId, index + 1));
|
||||
database.exec("COMMIT");
|
||||
} catch (cause) {
|
||||
database.exec("ROLLBACK");
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
refreshSiteProjection(database);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipe-books/${id}/`, 303);
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "Unable to save recipe book.";
|
||||
}
|
||||
}
|
||||
|
||||
const book = database.prepare("SELECT * FROM collections WHERE id=? AND deleted_at IS NULL").get(id) as any;
|
||||
if (!book) {
|
||||
database.close();
|
||||
return new Response("Recipe book not found", { status: 404 });
|
||||
}
|
||||
|
||||
const recipes = database.prepare(`
|
||||
SELECT r.id, r.title, cr.position, (cr.recipe_id IS NOT NULL) AS included
|
||||
FROM recipes r
|
||||
LEFT JOIN collection_recipes cr ON cr.recipe_id = r.id AND cr.collection_id = ?
|
||||
WHERE r.deleted_at IS NULL
|
||||
ORDER BY coalesce(cr.position, 999999), r.title
|
||||
`).all(id) as any[];
|
||||
|
||||
const includedRecipes = recipes.filter((r) => r.included);
|
||||
database.close();
|
||||
---
|
||||
<BaseLayout title={book.name} immersive><section class="entity-detail-shell recipe-book-detail"><DetailUtility/><header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=book">← Recipe books</a></p><h1>{book.name}</h1><p>{recipes.filter(x=>x.included).length} recipes</p></div>{!readOnlyMode&&<a class="edit-command" href={editing?`/app/recipe-books/${id}/`:`/app/recipe-books/${id}/?edit=1`}>{editing?"✓ Done":"✎ Edit"}</a>}</header>{error&&<p class="notice">{error}</p>}<main class="book-workspace">{editing&&<form method="post" class="book-details-form"><input type="hidden" name="intent" value="details"/><label><span>Name</span><input name="name" value={book.name} required/></label><label><span>Description</span><input name="description" value={book.description??""}/></label><button>Save details</button></form>}<form method="post" class:list={["book-membership",{"read-only":!editing}]}><input type="hidden" name="intent" value="membership"/><header><div><h2>Recipes</h2><p>{editing?"Choose the recipes included in this book.":book.description}</p></div>{editing&&<button>Save recipes</button>}</header>{recipes.filter(recipe=>editing||recipe.included).map(recipe=><label class="book-recipe-row">{editing&&<input type="checkbox" name="recipe_id" value={recipe.id} checked={recipe.included}/>}<span class="workspace-pill-icon recipe">▦</span><a href={`/app/recipes/${recipe.id}/`}><strong>{recipe.title}</strong></a></label>)}</form></main></section></BaseLayout>
|
||||
|
||||
<BaseLayout title={book.name} immersive>
|
||||
<div class="recipe-book-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="recipe-book-workspace">
|
||||
{error && <div class="notice book-notice">{error}</div>}
|
||||
|
||||
<header class="recipe-book-header">
|
||||
<div class="recipe-book-header-left">
|
||||
<nav class="recipe-book-breadcrumbs">
|
||||
<a href="/app/?type=book">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>Recipe books</span>
|
||||
</a>
|
||||
</nav>
|
||||
<h1>{book.name}</h1>
|
||||
<div class="recipe-book-meta">
|
||||
<span class="recipe-book-count-badge">
|
||||
<span class="count-number">{includedRecipes.length}</span> {includedRecipes.length === 1 ? "recipe" : "recipes"}
|
||||
</span>
|
||||
{book.description && <span class="recipe-book-desc">{book.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnlyMode && (
|
||||
<div class="recipe-book-header-actions">
|
||||
<a
|
||||
class:list={["book-action-btn", { active: editing }]}
|
||||
href={editing ? `/app/recipe-books/${id}/` : `/app/recipe-books/${id}/?edit=1`}
|
||||
>
|
||||
{editing ? (
|
||||
<>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
<span>Cancel</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
<span>Edit</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{editing ? (
|
||||
<form method="post" class="book-edit-form">
|
||||
<input type="hidden" name="intent" value="save" />
|
||||
|
||||
<section class="book-edit-card">
|
||||
<h2>Book Details</h2>
|
||||
<div class="book-field-group">
|
||||
<label>
|
||||
<span class="field-label">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={book.name}
|
||||
required
|
||||
placeholder="e.g. Signature Cocktails"
|
||||
class="book-input"
|
||||
autofocus
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="field-label">Description (optional)</span>
|
||||
<textarea
|
||||
name="description"
|
||||
rows="2"
|
||||
placeholder="Add context or notes about this recipe collection..."
|
||||
class="book-textarea"
|
||||
>{book.description ?? ""}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="book-edit-card">
|
||||
<div class="book-edit-card-header">
|
||||
<div>
|
||||
<h2>Select Recipes</h2>
|
||||
<p class="section-subtitle">
|
||||
Choose the recipes included in this book ({includedRecipes.length} currently selected)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="book-recipe-checklist">
|
||||
{recipes.map((recipe) => (
|
||||
<label class="book-checklist-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="recipe_id"
|
||||
value={recipe.id}
|
||||
checked={recipe.included}
|
||||
class="book-checkbox"
|
||||
/>
|
||||
<span class="book-recipe-icon">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z" transform="scale(1.1, 1.1) translate(4px, 3.5px)"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="book-checklist-label">
|
||||
<strong>{recipe.title}</strong>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="book-edit-actions">
|
||||
<button type="submit" class="book-save-btn">Save changes</button>
|
||||
<a href={`/app/recipe-books/${id}/`} class="book-cancel-link">Cancel</a>
|
||||
</footer>
|
||||
</form>
|
||||
) : (
|
||||
<section class="book-view-section">
|
||||
{includedRecipes.length > 0 ? (
|
||||
<div class="book-directory-table">
|
||||
<div class="book-directory-toolbar">
|
||||
<span class="book-toolbar-title">Included Recipes</span>
|
||||
<span class="book-toolbar-count">{includedRecipes.length} {includedRecipes.length === 1 ? "recipe" : "recipes"}</span>
|
||||
</div>
|
||||
<div class="book-recipe-list">
|
||||
{includedRecipes.map((recipe) => (
|
||||
<div class="book-recipe-row">
|
||||
<span class="book-recipe-icon">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z" transform="scale(1.1, 1.1) translate(4px, 3.5px)"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="book-recipe-info">
|
||||
<a href={`/app/recipes/${recipe.id}/`} class="book-recipe-title">
|
||||
{recipe.title}
|
||||
</a>
|
||||
</div>
|
||||
<a href={`/app/recipes/${recipe.id}/`} class="book-recipe-arrow" aria-label={`View ${recipe.title}`}>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="book-empty-state">
|
||||
<span class="empty-icon">▣</span>
|
||||
<h3>No recipes in this book yet</h3>
|
||||
<p>Organize your recipes by adding them to this book.</p>
|
||||
{!readOnlyMode && (
|
||||
<a href={`/app/recipe-books/${id}/?edit=1`} class="book-add-recipes-btn">
|
||||
✎ Add recipes
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,8 +1,93 @@
|
||||
---
|
||||
export const prerender=false;
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import { openDatabase,refreshSiteProjection } from "../../../../lib/database";
|
||||
let error:string|undefined;
|
||||
if(Astro.request.method==="POST"){const database=openDatabase({readOnly:false});if(!database)return Astro.redirect("/app/?error=database-missing",303);try{const form=await Astro.request.formData();const name=String(form.get("name")??"").trim();const description=String(form.get("description")??"").trim()||null;if(!name)throw new Error("Name is required.");const base=name.toLocaleLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g,"_").replace(/^_|_$/g,"")||"recipe_book";let id=base,suffix=2;while(database.prepare("SELECT 1 FROM collections WHERE id=?").get(id))id=`${base}_${suffix++}`;database.prepare("INSERT INTO collections(id,name,description,source_json) VALUES (?,?,?,'{}')").run(id,name,description);refreshSiteProjection(database);database.close();return Astro.redirect("/app/?type=book",303);}catch(cause){error=cause instanceof Error?cause.message:"Unable to create recipe book.";database.close();}}
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import { openDatabase, refreshSiteProjection } from "../../../../lib/database";
|
||||
|
||||
let error: string | undefined;
|
||||
|
||||
if (Astro.request.method === "POST") {
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Astro.redirect("/app/?error=database-missing", 303);
|
||||
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
const name = String(form.get("name") ?? "").trim();
|
||||
const description = String(form.get("description") ?? "").trim() || null;
|
||||
if (!name) throw new Error("Name is required.");
|
||||
|
||||
const base = name.toLocaleLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "") || "recipe_book";
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (database.prepare("SELECT 1 FROM collections WHERE id=?").get(id)) {
|
||||
id = `${base}_${suffix++}`;
|
||||
}
|
||||
|
||||
database.prepare("INSERT INTO collections(id, name, description, source_json) VALUES (?, ?, ?, '{}')").run(id, name, description);
|
||||
refreshSiteProjection(database);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipe-books/${id}/`, 303);
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "Unable to create recipe book.";
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
---
|
||||
<BaseLayout title="New recipe book"><section class="shell page-heading"><p class="eyebrow"><a href="/app/?type=book">Recipe books</a></p><h1>New recipe book</h1><p>Create a collection for organizing recipes.</p></section><section class="shell create-entity"><form method="post" class="editor-form"><fieldset><legend>Recipe book details</legend>{error&&<div class="notice">{error}</div>}<label><span>Name</span><input name="name" required autofocus /></label><label><span>Description</span><textarea name="description" rows="4"></textarea></label></fieldset><div class="editor-actions"><button>Create recipe book</button><a href="/app/?type=book">Cancel</a></div></form></section></BaseLayout>
|
||||
|
||||
<BaseLayout title="New recipe book" immersive>
|
||||
<div class="recipe-book-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="recipe-book-workspace">
|
||||
{error && <div class="notice book-notice">{error}</div>}
|
||||
|
||||
<header class="recipe-book-header">
|
||||
<div class="recipe-book-header-left">
|
||||
<nav class="recipe-book-breadcrumbs">
|
||||
<a href="/app/?type=book">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>Recipe books</span>
|
||||
</a>
|
||||
</nav>
|
||||
<h1>New recipe book</h1>
|
||||
<p class="recipe-book-subtitle">Create a collection for organizing recipes.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form method="post" class="book-edit-form">
|
||||
<section class="book-edit-card">
|
||||
<h2>Recipe Book Details</h2>
|
||||
<div class="book-field-group">
|
||||
<label>
|
||||
<span class="field-label">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
autofocus
|
||||
placeholder="e.g. Pastry & Bakes, Cocktail Program"
|
||||
class="book-input"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="field-label">Description (optional)</span>
|
||||
<textarea
|
||||
name="description"
|
||||
rows="3"
|
||||
placeholder="Describe what belongs in this recipe book..."
|
||||
class="book-textarea"
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="book-edit-actions">
|
||||
<button type="submit" class="book-save-btn">Create recipe book</button>
|
||||
<a href="/app/?type=book" class="book-cancel-link">Cancel</a>
|
||||
</footer>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import { databaseProjection, duplicateRecipe, editableRecipe, openDatabase, refreshSiteProjection, saveRecipeMetadata } from "../../../../lib/database";
|
||||
import { recipeStructure } from "../../../../lib/database";
|
||||
import {
|
||||
editableRecipe,
|
||||
openDatabase,
|
||||
refreshSiteProjection,
|
||||
saveRecipeMetadata,
|
||||
duplicateRecipe,
|
||||
recipeStructure,
|
||||
} from "../../../../lib/database";
|
||||
import { getRecipeCalculationContext } from "../../../../lib/repository";
|
||||
import RecipeStructureEditor from "../../../../components/RecipeStructureEditor";
|
||||
import RecipeCalculator, { LiveCostValues, LiveNutritionValues } from "../../../../components/RecipeCalculator";
|
||||
import { calculateNutrition } from "../../../../lib/nutrition";
|
||||
import { calculateCost } from "../../../../lib/costing";
|
||||
import type { Ingredient, PrepAction, PurchaseItem, Recipe, SourceMapping, Unit } from "../../../../lib/types";
|
||||
import type { Ingredient, Recipe } from "../../../../lib/types";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import { convertWithIngredientMeasures } from "../../../../lib/measurement";
|
||||
import { readOnlyMode } from "../../../../lib/runtime";
|
||||
@@ -21,22 +28,18 @@ let error: string | undefined;
|
||||
if (Astro.request.method === "POST") {
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
if(form.get("intent")==="media"){
|
||||
const url=String(form.get("url")??"").trim(),mediaType=String(form.get("media_type")??"image"),stepId=String(form.get("step_id")??"").trim()||null;if(!url)throw new Error("Media URL is required.");if(!["image","video"].includes(mediaType))throw new Error("Invalid media type.");
|
||||
const position=(database.prepare("SELECT coalesce(max(position),0)+1 position FROM recipe_media WHERE recipe_id=?").get(id) as any).position;database.prepare("INSERT INTO recipe_media(recipe_id,id,step_id,media_type,url,caption,position) VALUES (?,?,?,?,?,?,?)").run(id,`media_${Date.now()}`,stepId,mediaType,url,String(form.get("caption")??"").trim()||null,position);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#additional`,303);
|
||||
}
|
||||
if(form.get("intent")==="additional"){
|
||||
const row=database.prepare("SELECT source_json FROM recipes WHERE id=?").get(id) as any,source=JSON.parse(row.source_json??"{}");
|
||||
const shelfQuantity=Number(form.get("shelf_quantity")),shelfUnit=String(form.get("shelf_unit")??"").trim();
|
||||
if(shelfQuantity>0&&shelfUnit)source.shelf_life={duration:{quantity:shelfQuantity,unit_id:shelfUnit},storage_condition:String(form.get("storage_condition")??"").trim()||undefined};else delete source.shelf_life;
|
||||
const notes=String(form.get("notes")??"").split("\n").map(value=>value.trim()).filter(Boolean);
|
||||
database.prepare("UPDATE recipes SET station=?,cover_media_url=?,notes_json=?,source_json=? WHERE id=?").run(String(form.get("station")??"").trim()||null,String(form.get("cover_media_url")??"").trim()||null,JSON.stringify(notes),JSON.stringify(source),id);
|
||||
database.prepare("UPDATE recipes SET station=?,notes_json=?,source_json=? WHERE id=?").run(String(form.get("station")??"").trim()||null,JSON.stringify(notes),JSON.stringify(source),id);
|
||||
refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#additional`,303);
|
||||
}
|
||||
if (form.get("intent") === "duplicate") {
|
||||
const redirectTo = `/app/recipes/${duplicateRecipe(database, id)}/?edit=1`;
|
||||
const duplicatedId = duplicateRecipe(database, id);
|
||||
database.close();
|
||||
return Astro.redirect(redirectTo, 303);
|
||||
return Astro.redirect(`/app/recipes/${duplicatedId}/?edit=1`, 303);
|
||||
}
|
||||
if(form.get("intent")==="auto_yield"){
|
||||
const current=editableRecipe(database,id);if(!current)throw new Error("Recipe not found.");
|
||||
@@ -47,34 +50,53 @@ if (Astro.request.method === "POST") {
|
||||
if(original?.quantity>0&&original?.unit_id)saveRecipeMetadata(database,id,current.save_version,{title:current.title,summary:current.summary,categories_json:current.categories_json,tags_json:current.tags_json,yield_quantity:original.quantity,yield_unit_id:original.unit_id,yield_servings:current.yield_servings,yield_basis:original.basis??null});
|
||||
delete source.auto_yield_original;database.prepare("UPDATE recipes SET auto_yield=0,source_json=? WHERE id=?").run(JSON.stringify(source),id);refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1&saved=1`,303);
|
||||
}
|
||||
const projectionNow=databaseProjection(database) as {recipes:Recipe[];ingredients:Ingredient[];units:Unit[];sourceMappings:SourceMapping[]};
|
||||
const recipesNow=new Map(projectionNow.recipes.map(value=>[value.id,value])),ingredientsNow=new Map(projectionNow.ingredients.map(value=>[value.id,value])),unitsNow=new Map(projectionNow.units.map(value=>[value.id,value]));
|
||||
const autoRecipe=recipesNow.get(id)!;let calculatedYieldWeightG=0;const conversionFailures:string[]=[];
|
||||
for(const item of autoRecipe.components.flatMap(component=>component.items).filter(item=>!item.optional)){
|
||||
try{if("ingredient_id" in item.reference){const ingredient=ingredientsNow.get(item.reference.ingredient_id);if(!ingredient)throw new Error("ingredient not found");calculatedYieldWeightG+=convertWithIngredientMeasures(item.amount,"gram",ingredient,unitsNow).quantity;}else{const child=recipesNow.get(item.reference.recipe_id);if(!child)throw new Error("sub-recipe not found");calculatedYieldWeightG+=convertWithIngredientMeasures(item.amount,"gram",{schema_version:2,id:child.id,name:child.title,status:"active",categories:[],measure_conversions:child.measure_conversions},unitsNow).quantity;}}catch{conversionFailures.push("ingredient_id" in item.reference?ingredientsNow.get(item.reference.ingredient_id)?.name??item.reference.ingredient_id:recipesNow.get(item.reference.recipe_id)?.title??item.reference.recipe_id);}
|
||||
const calcContextNow = getRecipeCalculationContext(database, id);
|
||||
if (!calcContextNow) throw new Error("Recipe not found.");
|
||||
const { recipes: recipesNow, ingredients: ingredientsNow, units: unitsNow, domainRecipe: autoRecipe } = calcContextNow;
|
||||
let calculatedYieldWeightG = 0;
|
||||
const conversionFailures: string[] = [];
|
||||
for (const item of autoRecipe.components.flatMap(component => component.items).filter(item => !item.optional)) {
|
||||
try {
|
||||
if ("ingredient_id" in item.reference) {
|
||||
const ingredient = ingredientsNow.get(item.reference.ingredient_id);
|
||||
if (!ingredient) throw new Error("ingredient not found");
|
||||
calculatedYieldWeightG += convertWithIngredientMeasures(item.amount, "gram", ingredient, unitsNow).quantity;
|
||||
} else {
|
||||
const child = recipesNow.get(item.reference.recipe_id);
|
||||
if (!child) throw new Error("sub-recipe not found");
|
||||
calculatedYieldWeightG += convertWithIngredientMeasures(item.amount, "gram", { schema_version: 2, id: child.id, name: child.title, status: "active", categories: [], measure_conversions: child.measure_conversions }, unitsNow).quantity;
|
||||
}
|
||||
} catch {
|
||||
conversionFailures.push("ingredient_id" in item.reference ? ingredientsNow.get(item.reference.ingredient_id)?.name ?? item.reference.ingredient_id : recipesNow.get(item.reference.recipe_id)?.title ?? item.reference.recipe_id);
|
||||
}
|
||||
}
|
||||
if(conversionFailures.length)throw new Error(`Auto calculate total yield needs a weight equivalency for: ${conversionFailures.join(", ")}.`);
|
||||
if(!(calculatedYieldWeightG>0))throw new Error("No convertible ingredient weights are available for automatic yield.");
|
||||
source.auto_yield_original={quantity:current.yield_quantity,unit_id:current.yield_unit_id,basis:current.yield_basis};
|
||||
saveRecipeMetadata(database,id,current.save_version,{title:current.title,summary:current.summary,categories_json:current.categories_json,tags_json:current.tags_json,yield_quantity:calculatedYieldWeightG,yield_unit_id:"gram",yield_servings:current.yield_servings,yield_basis:"theoretical"});
|
||||
database.prepare("UPDATE recipes SET auto_yield=1,source_json=? WHERE id=?").run(JSON.stringify(source),id);refreshSiteProjection(database);
|
||||
database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1&saved=1#nutrition`,303);
|
||||
if (conversionFailures.length) throw new Error(`Auto calculate total yield needs a weight equivalency for: ${conversionFailures.join(", ")}.`);
|
||||
if (!(calculatedYieldWeightG > 0)) throw new Error("No convertible ingredient weights are available for automatic yield.");
|
||||
source.auto_yield_original = { quantity: current.yield_quantity, unit_id: current.yield_unit_id, basis: current.yield_basis };
|
||||
saveRecipeMetadata(database, id, current.save_version, { title: current.title, summary: current.summary, categories_json: current.categories_json, tags_json: current.tags_json, yield_quantity: calculatedYieldWeightG, yield_unit_id: "gram", yield_servings: current.yield_servings, yield_basis: "theoretical" });
|
||||
database.prepare("UPDATE recipes SET auto_yield=1,source_json=? WHERE id=?").run(JSON.stringify(source), id);
|
||||
refreshSiteProjection(database);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipes/${id}/?edit=1&saved=1#nutrition`, 303);
|
||||
}
|
||||
if (form.get("intent") === "nutrition_servings") {
|
||||
const current=editableRecipe(database,id);if(!current)throw new Error("Recipe not found.");
|
||||
const yieldServings=Number(form.get("yield_servings"));
|
||||
if(!Number.isFinite(yieldServings)||yieldServings<=0)throw new Error("Servings must be greater than zero.");
|
||||
const expectedVersion=Number(form.get("save_version"));
|
||||
saveRecipeMetadata(database,id,expectedVersion,{title:current.title,summary:current.summary,categories_json:current.categories_json,tags_json:current.tags_json,yield_quantity:current.yield_quantity,yield_unit_id:current.yield_unit_id,yield_servings:yieldServings,yield_basis:current.yield_basis});
|
||||
database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#nutrition`,303);
|
||||
const current = editableRecipe(database, id);
|
||||
if (!current) throw new Error("Recipe not found.");
|
||||
const yieldServings = Number(form.get("yield_servings"));
|
||||
if (!Number.isFinite(yieldServings) || yieldServings <= 0) throw new Error("Servings must be greater than zero.");
|
||||
const expectedVersion = Number(form.get("save_version"));
|
||||
saveRecipeMetadata(database, id, expectedVersion, { title: current.title, summary: current.summary, categories_json: current.categories_json, tags_json: current.tags_json, yield_quantity: current.yield_quantity, yield_unit_id: current.yield_unit_id, yield_servings: yieldServings, yield_basis: current.yield_basis });
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipes/${id}/?edit=1#nutrition`, 303);
|
||||
}
|
||||
if (form.get("intent") === "conversion") {
|
||||
const fromQuantity=Number(form.get("from_quantity")),toQuantity=Number(form.get("to_quantity"));
|
||||
const fromUnitId=String(form.get("from_unit_id")??""),toUnitId=String(form.get("to_unit_id")??"");
|
||||
if(!(fromQuantity>0&&toQuantity>0)) throw new Error("Equivalency quantities must be positive.");
|
||||
if(!database.prepare("SELECT 1 FROM units WHERE id IN (?,?) HAVING count(*)=2").get(fromUnitId,toUnitId)) throw new Error("Unknown equivalency unit.");
|
||||
database.prepare("INSERT INTO recipe_measure_conversions VALUES (?,?,?,?,?,?,?,?)").run(id,`manual_${Date.now()}`,fromQuantity,fromUnitId,toQuantity,toUnitId,String(form.get("notes")??"").trim()||null,JSON.stringify({source_type:"manual",title:"Recipe application",reviewed:true}));
|
||||
database.close(); return Astro.redirect(`/app/recipes/${id}/?edit=1#equivalencies`,303);
|
||||
const fromQuantity = Number(form.get("from_quantity")), toQuantity = Number(form.get("to_quantity"));
|
||||
const fromUnitId = String(form.get("from_unit_id") ?? ""), toUnitId = String(form.get("to_unit_id") ?? "");
|
||||
if (!(fromQuantity > 0 && toQuantity > 0)) throw new Error("Equivalency quantities must be positive.");
|
||||
if (!database.prepare("SELECT 1 FROM units WHERE id IN (?,?) HAVING count(*)=2").get(fromUnitId, toUnitId)) throw new Error("Unknown equivalency unit.");
|
||||
database.prepare("INSERT INTO recipe_measure_conversions VALUES (?,?,?,?,?,?,?,?)").run(id, `manual_${Date.now()}`, fromQuantity, fromUnitId, toQuantity, toUnitId, String(form.get("notes") ?? "").trim() || null, JSON.stringify({ source_type: "manual", title: "Recipe application", reviewed: true }));
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipes/${id}/?edit=1#equivalencies`, 303);
|
||||
}
|
||||
const title = String(form.get("title") ?? "").trim();
|
||||
const yieldQuantity = Number(form.get("yield_quantity"));
|
||||
@@ -107,26 +129,29 @@ const recipe = editableRecipe(database, id);
|
||||
if (!recipe) { database.close(); return new Response("Recipe not found", { status: 404 }); }
|
||||
const units = database.prepare("SELECT id, name, symbol, dimension FROM units ORDER BY dimension, name").all() as Array<{ id: string; name: string; symbol: string; dimension: string }>;
|
||||
const structure = recipeStructure(database, id)!;
|
||||
const autoYield=Boolean((database.prepare("SELECT auto_yield FROM recipes WHERE id=?").get(id) as {auto_yield:number}).auto_yield);
|
||||
const ingredientOptions = database.prepare("SELECT id, name FROM ingredients WHERE status = 'active' ORDER BY name").all() as Array<{ id: string; name: string }>;
|
||||
const autoYield = Boolean((database.prepare("SELECT auto_yield FROM recipes WHERE id=?").get(id) as { auto_yield: number }).auto_yield);
|
||||
const ingredientOptions = (database.prepare("SELECT id, name FROM ingredients WHERE status = 'active' ORDER BY name").all() as Array<{ id: string; name: string }>).map((ingredient) => ({
|
||||
...ingredient,
|
||||
aliases: (database.prepare("SELECT name FROM ingredient_aliases WHERE ingredient_id = ? ORDER BY name").all(ingredient.id) as Array<{ name: string }>).map((entry) => entry.name),
|
||||
}));
|
||||
const recipeOptions = database.prepare("SELECT id, title AS name FROM recipes WHERE deleted_at IS NULL ORDER BY title").all() as Array<{ id: string; name: string }>;
|
||||
const prepActionOptions = database.prepare("SELECT id, name FROM prep_actions ORDER BY name").all() as Array<{ id: string; name: string }>;
|
||||
const recipeConversions=database.prepare("SELECT * FROM recipe_measure_conversions WHERE recipe_id=? ORDER BY id").all(id) as any[];
|
||||
const additional=database.prepare("SELECT station,cover_media_url,notes_json,source_json FROM recipes WHERE id=?").get(id) as any;
|
||||
const additionalSource=JSON.parse(additional.source_json??"{}"),shelfLife=additionalSource.shelf_life;
|
||||
const media=database.prepare("SELECT * FROM recipe_media WHERE recipe_id=? ORDER BY position").all(id) as any[];
|
||||
const projection = databaseProjection(database) as { recipes:Recipe[]; ingredients:Ingredient[]; units:Unit[]; sourceMappings:SourceMapping[]; purchaseItems:PurchaseItem[]; prepActions:PrepAction[] };
|
||||
const domainRecipe = projection.recipes.find((entry) => entry.id === id)!;
|
||||
const recipeMap = new Map(projection.recipes.map((entry) => [entry.id, entry]));
|
||||
const ingredientMap = new Map(projection.ingredients.map((entry) => [entry.id, entry]));
|
||||
const unitMap = new Map(projection.units.map((entry) => [entry.id, entry]));
|
||||
const nutrition = calculateNutrition(domainRecipe, { recipes:recipeMap, ingredients:ingredientMap, units:unitMap, mappings:new Map(projection.sourceMappings.map((entry) => [entry.id, entry])) });
|
||||
const cost = calculateCost(domainRecipe, { recipes:recipeMap, ingredients:ingredientMap, units:unitMap, purchaseItems:new Map(projection.purchaseItems.map((entry) => [entry.id, entry])), prepActions:new Map(projection.prepActions.map((entry) => [entry.id, entry])) });
|
||||
const calculatorComponents = domainRecipe.components.map((component) => ({ ...component, items:component.items.map((item) => { const ingredient="ingredient_id" in item.reference ? ingredientMap.get(item.reference.ingredient_id) : undefined; const child="recipe_id" in item.reference ? recipeMap.get(item.reference.recipe_id) : undefined; return { ...item, basisMember:item.basis_member, label:ingredient?.name ?? child?.title ?? "Unknown", href:ingredient ? `/app/ingredients/${ingredient.id}/` : child ? `/app/recipes/${child.id}/` : undefined, measureConversions:ingredient?.measure_conversions ?? child?.measure_conversions ?? [] }; }) }));
|
||||
const percentSubjectEntries=domainRecipe.components.flatMap(component=>component.items).map(item=>{const ingredient="ingredient_id" in item.reference?ingredientMap.get(item.reference.ingredient_id):undefined,child="recipe_id" in item.reference?recipeMap.get(item.reference.recipe_id):undefined;return ingredient?[`ingredient:${ingredient.id}`,{key:`ingredient:${ingredient.id}`,value:ingredient}] as [string,{key:string,value:Ingredient|Recipe}]:[`recipe:${child?.id}`,{key:`recipe:${child?.id}`,value:child!}] as [string,{key:string,value:Ingredient|Recipe}];});
|
||||
const percentSubjects=[...new Map<string,{key:string,value:Ingredient|Recipe}>(percentSubjectEntries).values()];
|
||||
const weightRates=Object.fromEntries(percentSubjects.flatMap(subject=>projection.units.map(unit=>{try{return [`${subject.key}:${unit.id}`,convertWithIngredientMeasures({quantity:1,unit_id:unit.id},"gram",subject.value as Ingredient,unitMap).quantity];}catch{return [`${subject.key}:${unit.id}`,null];}})));
|
||||
const nutritionMappingMap=new Map(projection.sourceMappings.map(mapping=>[mapping.id,mapping]));
|
||||
const recipeConversions = database.prepare("SELECT * FROM recipe_measure_conversions WHERE recipe_id=? ORDER BY id").all(id) as any[];
|
||||
const additional = database.prepare("SELECT station,notes_json,source_json FROM recipes WHERE id=?").get(id) as any;
|
||||
const additionalSource = JSON.parse(additional.source_json ?? "{}"), shelfLife = additionalSource.shelf_life;
|
||||
|
||||
const calcContext = getRecipeCalculationContext(database, id);
|
||||
if (!calcContext) { database.close(); return new Response("Recipe calculation context not found", { status: 404 }); }
|
||||
const { domainRecipe, recipes: recipeMap, ingredients: ingredientMap, units: unitMap, purchaseItems: purchaseItemsMap, sourceMappings: sourceMappingsMap, prepActions: prepActionsMap } = calcContext;
|
||||
|
||||
const nutrition = calculateNutrition(domainRecipe, { recipes: recipeMap, ingredients: ingredientMap, units: unitMap, mappings: sourceMappingsMap });
|
||||
const cost = calculateCost(domainRecipe, { recipes: recipeMap, ingredients: ingredientMap, units: unitMap, purchaseItems: purchaseItemsMap, prepActions: prepActionsMap });
|
||||
const reviewedNutritionMappingIds = new Set(Array.from(sourceMappingsMap.values()).filter((mapping) => mapping.mapping_type === "nutrition" && mapping.status === "reviewed" && Object.keys(mapping.nutrition_per_100g ?? {}).length > 0).map((mapping) => mapping.id));
|
||||
const calculatorComponents = domainRecipe.components.map((component) => ({ ...component, items: component.items.map((item) => { const ingredient = "ingredient_id" in item.reference ? ingredientMap.get(item.reference.ingredient_id) : undefined; const child = "recipe_id" in item.reference ? recipeMap.get(item.reference.recipe_id) : undefined; const mapped = ingredient ? (ingredient.nutrition_mapping_ids ?? []).some((mappingId) => reviewedNutritionMappingIds.has(mappingId)) : true; return { ...item, basisMember: item.basis_member, label: ingredient?.name ?? child?.title ?? "Unknown", href: ingredient ? `/app/ingredients/${ingredient.id}/` : child ? `/app/recipes/${child.id}/` : undefined, attention: Boolean(ingredient && !mapped), attentionMessage: ingredient && !mapped ? "Nutrition mapping needed" : undefined, measureConversions: ingredient?.measure_conversions ?? child?.measure_conversions ?? [] }; }) }));
|
||||
const percentSubjectEntries = domainRecipe.components.flatMap(component => component.items).map(item => { const ingredient = "ingredient_id" in item.reference ? ingredientMap.get(item.reference.ingredient_id) : undefined, child = "recipe_id" in item.reference ? recipeMap.get(item.reference.recipe_id) : undefined; return ingredient ? [`ingredient:${ingredient.id}`, { key: `ingredient:${ingredient.id}`, value: ingredient }] as [string, { key: string, value: Ingredient | Recipe }] : [`recipe:${child?.id}`, { key: `recipe:${child?.id}`, value: child! }] as [string, { key: string, value: Ingredient | Recipe }]; });
|
||||
const percentSubjects = [...new Map<string, { key: string, value: Ingredient | Recipe }>(percentSubjectEntries).values()];
|
||||
const weightRates = Object.fromEntries(percentSubjects.flatMap(subject => Array.from(unitMap.values()).map(unit => { try { return [`${subject.key}:${unit.id}`, convertWithIngredientMeasures({ quantity: 1, unit_id: unit.id }, "gram", subject.value as Ingredient, unitMap).quantity]; } catch { return [`${subject.key}:${unit.id}`, null]; } })));
|
||||
const nutritionMappingMap = sourceMappingsMap;
|
||||
const nutritionIngredients=domainRecipe.components.flatMap(component=>component.items).filter(item=>!item.optional).map(item=>{
|
||||
if("ingredient_id" in item.reference){
|
||||
const ingredient=ingredientMap.get(item.reference.ingredient_id)!;
|
||||
@@ -143,42 +168,297 @@ database.close();
|
||||
const categories = JSON.parse(recipe.categories_json).join(", ");
|
||||
const tags = JSON.parse(recipe.tags_json).join(", ");
|
||||
const saved = Astro.url.searchParams.get("saved") === "1";
|
||||
const RECIPE_TAB_ICONS={
|
||||
formula:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z",
|
||||
method:"M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5m0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5m0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5M7 19h14v-2H7zm0-6h14v-2H7zm0-8v2h14V5z",
|
||||
costing:"M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4",
|
||||
equivalencies:"M19.4 3.3h-6.6v-.5c0-.4-.3-.7-.8-.7-.4 0-.8.3-.8.7v.5H4.6L0 14s.2 3.8 4.7 3.8S9.4 14 9.4 14L6.1 6.2h5.1v15.7h1.5V6.2h5.1L14.6 14s.2 3.8 4.7 3.8S24 14 24 14L19.4 3.3zM7.7 14H1.5l3.1-7.4L7.7 14zm8.5 0l3.1-7.4 3.1 7.4h-6.2z",
|
||||
nutrition:"M9.42859 2.37431L9.80926 2.82134L10.1899 2.37431C11.1674 1.22652 12.668 0.5 14.2234 0.5C16.9685 0.5 19.1185 2.64998 19.1185 5.3951C19.1185 7.0848 18.3631 8.65707 16.9325 10.4062C15.4961 12.1623 13.4317 14.0352 10.8957 16.3348L10.895 16.3354L9.80799 17.325L8.72319 16.345L8.72211 16.344L8.71119 16.3341C6.18062 14.0344 4.12043 12.1623 2.68618 10.4075C1.25541 8.6571 0.5 7.08481 0.5 5.3951C0.5 2.64998 2.64998 0.5 5.3951 0.5C6.95051 0.5 8.45117 1.22652 9.42859 2.37431ZM3.70568 10.127C5.0829 11.7363 7.04455 13.5134 9.36637 15.6157L9.45571 15.7051L9.80926 16.0586L10.1628 15.7051L10.2522 15.6157C12.574 13.5134 14.5356 11.7363 15.9128 10.127C17.287 8.52131 18.1567 6.99709 18.1567 5.3951C18.1567 3.1571 16.4614 1.46185 14.2234 1.46185C12.6415 1.46185 11.0895 2.39876 10.4049 3.77684H9.22149C8.52967 2.40009 6.97866 1.46185 5.3951 1.46185C3.1571 1.46185 1.46185 3.1571 1.46185 5.3951C1.46185 6.99709 2.3315 8.52131 3.70568 10.127Z"
|
||||
};
|
||||
const RECIPE_TAB_VIEWBOX={formula:"0 0 14 16",method:"0 0 24 24",costing:"0 0 24 24",equivalencies:"0 0 24 24",nutrition:"0 0 20 18"};
|
||||
const recipeTabIcon=(name:string)=>`<span class="recipe-tab-icon"><svg viewBox="${RECIPE_TAB_VIEWBOX[name as keyof typeof RECIPE_TAB_VIEWBOX]}" aria-hidden="true"><path fill="currentColor" d="${RECIPE_TAB_ICONS[name as keyof typeof RECIPE_TAB_ICONS]}"/></svg></span>`;
|
||||
---
|
||||
<BaseLayout title={editing?`Edit ${recipe.title}`:recipe.title} immersive>
|
||||
<section class="recipe-detail-shell">
|
||||
<DetailUtility section="Recipes" sectionHref="/app/?type=recipe" />
|
||||
<header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=recipe">← Recipes</a></p>{editing?<input class="editable-entity-title" name="title" value={recipe.title} form="recipe-details-form" aria-label="Recipe name" required/>:<h1>{recipe.title}</h1>}</div>{!readOnlyMode&&<div class="entity-header-actions">{editing?<button class="primary-command" id="recipe-done" type="button" data-view-url={`/app/recipes/${id}/`}>✓ Done</button>:<a class="edit-command" href={`/app/recipes/${id}/?edit=1`}>✎ Edit</a>}<details class="detail-actions-menu"><summary aria-label="Recipe actions">⋮</summary><div><form method="post"><button name="intent" value="duplicate">Duplicate recipe</button></form></div></details></div>}</header>
|
||||
<div class="recipe-workspace-tabs">{editing?<><button class="active" type="button" data-edit-recipe-tab="method">☷ Prep Method</button><button type="button" data-edit-recipe-tab="costing">$ Cost</button><button type="button" data-edit-recipe-tab="equivalencies">⚖ UoM Equivalency</button><button type="button" data-edit-recipe-tab="nutrition">♡ Nutrition</button></>:<><button class="active" type="button" data-recipe-tab="method">☷ Prep Method</button><button type="button" data-recipe-tab="costing">$ Cost</button><button type="button" data-recipe-tab="equivalencies">⚖ UoM Equivalency</button><button type="button" data-recipe-tab="nutrition">♡ Nutrition</button></>}</div>
|
||||
{editing?<><section class="recipe-overview-strip">
|
||||
<form method="post" id="recipe-details-form" class="editor-form recipe-overview-form">
|
||||
<input type="hidden" name="save_version" value={recipe.save_version} />
|
||||
{saved && <div class="success-notice">Changes saved.</div>}
|
||||
{error && <div class="notice">{error}</div>}
|
||||
<div class:list={["inline-yield-editor",{"auto-calculated":autoYield}]}><span>Finished Yield</span><label><input name="yield_quantity" type="number" min="0.0001" step="any" required value={recipe.yield_quantity} placeholder="Qty" aria-label="Finished yield quantity" readonly={autoYield}/></label><label><select name="yield_unit_id" aria-label="Finished yield unit" aria-disabled={autoYield}>{units.map((unit) => <option value={unit.id} selected={recipe.yield_unit_id === unit.id}>{unit.symbol}</option>)}</select></label><input type="hidden" name="yield_servings" value={recipe.yield_servings??""}/><input type="hidden" name="yield_basis" value={recipe.yield_basis??""}/>{autoYield&&<small>Calculated from convertible ingredient quantities</small>}</div>
|
||||
<input type="hidden" name="summary" value={recipe.summary??""}/><input type="hidden" name="categories" value={categories}/><input type="hidden" name="tags" value={tags}/>
|
||||
</form>
|
||||
<form method="post" class:list={["inline-auto-yield",{active:autoYield}]}><input type="hidden" name="intent" value="auto_yield"/><button class="toggle-button" aria-label={`${autoYield?"Disable":"Enable"} automatic total yield`}><i></i></button><span>Auto calculate total yield</span>{autoYield&&<small><b>Revert</b> to original and disable auto calculate</small>}</form>
|
||||
</section>
|
||||
<section id="structure" class="recipe-structure-workspace unified-recipe-editor"><RecipeStructureEditor client:load recipeId={recipe.id} initial={structure} ingredients={ingredientOptions} recipes={recipeOptions} units={units} prepActions={prepActionOptions} weightRates={weightRates} autoYield={autoYield} showMethod={true}/></section>
|
||||
<section id="costing" class="recipe-edit-tab-panel" data-edit-recipe-panel="costing"><LiveCostValues client:load cost={cost}/></section>
|
||||
<section id="equivalencies" class="recipe-equivalence-editor recipe-edit-tab-panel" data-edit-recipe-panel="equivalencies"><h2>UoM Equivalency</h2><p>Define how this finished recipe converts between weight, volume, and portions.</p>{recipeConversions.map(x=><div><strong>{x.from_quantity} {x.from_unit_id}</strong><span>=</span><strong>{x.to_quantity} {x.to_unit_id}</strong><small>{x.notes}</small></div>)}<form method="post"><input name="from_quantity" type="number" min="0.0001" step="any" value="1"/><select name="from_unit_id">{units.map(x=><option value={x.id}>{x.name}</option>)}</select><span>=</span><input name="to_quantity" type="number" min="0.0001" step="any"/><select name="to_unit_id">{units.map(x=><option value={x.id}>{x.name}</option>)}</select><input name="notes" placeholder="Notes"/><button name="intent" value="conversion">Add equivalency</button></form></section>
|
||||
<section id="nutrition" class="recipe-edit-nutrition recipe-edit-tab-panel" data-edit-recipe-panel="nutrition"><LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients} editable saveVersion={recipe.save_version}/></section>
|
||||
<section id="additional" class="recipe-additional-editor"><h2>Additional Details</h2><form id="recipe-additional-form"><label class="cover-media-field"><span>{additional.cover_media_url?"Replace Cover Image":"Add Cover Image"}</span>{additional.cover_media_url&&<img src={additional.cover_media_url} alt=""/>}<input form="recipe-additional-form" name="cover_media_url" type="url" value={additional.cover_media_url??""} placeholder="Paste image URL"/></label><fieldset><legend>Shelf Life</legend><input name="shelf_quantity" type="number" min="0" step="any" value={shelfLife?.duration?.quantity??""} placeholder="Qty"/><select name="shelf_unit"><option value="">Unit</option>{["hour","day","week","month"].map(unit=><option value={unit} selected={shelfLife?.duration?.unit_id===unit}>{unit}</option>)}</select><input name="storage_condition" value={shelfLife?.storage_condition??""} placeholder="Storage condition"/></fieldset><label><span>Station</span><input name="station" value={additional.station??""} placeholder="Station Name"/></label><label><span>Tags</span><input name="tags" value={tags} placeholder="Tag Name"/></label></form></section>
|
||||
</>:<><section id="structure" class="recipe-view-workspace"><div class="recipe-view-formula"><RecipeCalculator client:load components={calculatorComponents} units={Object.fromEntries(unitMap)} basisUnitId={domainRecipe.scaling?.basis_amount?.unit_id??domainRecipe.yield.amount.unit_id} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnitId={domainRecipe.yield.amount.unit_id} yieldConversions={domainRecipe.measure_conversions??[]} mode={domainRecipe.scaling?.mode??"amount"} nutrition={nutrition} cost={cost} servings={domainRecipe.yield.servings} showDerived={false}/></div><div class="recipe-view-details"><section class="recipe-view-method recipe-tab-panel active" data-recipe-panel="method"><h2>Prep Method <small>{domainRecipe.steps.length}</small></h2><ol>{domainRecipe.steps.map(step=><li class:list={{placeholder:step.instruction.startsWith("TODO:")}}><strong>{step.order}.</strong><span>{step.instruction}{media.filter(entry=>entry.step_id===step.id).map(entry=><figure class="step-media">{entry.media_type==="image"?<img src={entry.url} alt={entry.caption??""}/>:<video src={entry.url} controls/>}{entry.caption&&<figcaption>{entry.caption}</figcaption>}</figure>)}</span></li>)}</ol></section><section class="recipe-tab-panel recipe-view-equivalencies" data-recipe-panel="equivalencies"><h2>UoM Equivalency</h2>{recipeConversions.length?recipeConversions.map(x=><div><strong>{x.from_quantity} {x.from_unit_id}</strong><span>=</span><strong>{x.to_quantity} {x.to_unit_id}</strong><small>{x.notes}</small></div>):<p>No recipe-level equivalencies have been defined.</p>}</section><section id="costing" class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="costing"><LiveCostValues client:load cost={cost}/></section><section id="nutrition" class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="nutrition"><LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients}/></section></div></section><section class="recipe-additional-view">{additional.cover_media_url&&<figure><img src={additional.cover_media_url} alt="" loading="lazy"/></figure>}<div><h2>Additional details</h2>{additional.station&&<p><strong>Station</strong><span>{additional.station}</span></p>}{shelfLife&&<p><strong>Shelf life</strong><span>{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}</span></p>}{JSON.parse(additional.notes_json??"[]").length>0&&<ul>{JSON.parse(additional.notes_json).map((note:string)=><li>{note}</li>)}</ul>}</div></section></>}
|
||||
<section class="recipe-detail-shell recipe-read-shell">
|
||||
<DetailUtility section="Recipes" sectionHref="/app/?type=recipe">
|
||||
<div class="entity-header-actions">
|
||||
{editing ? (
|
||||
<button class="primary-command" id="recipe-done" type="button" data-view-url={`/app/recipes/${id}/`}>✓ Done</button>
|
||||
) : !readOnlyMode && (
|
||||
<a class="edit-command" href={`/app/recipes/${id}/?edit=1`}><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>Edit</a>
|
||||
)}
|
||||
<details class="detail-actions-menu">
|
||||
<summary aria-label="Recipe actions"><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg></summary>
|
||||
<div><form method="post"><button name="intent" value="duplicate">Duplicate recipe</button></form></div>
|
||||
</details>
|
||||
</div>
|
||||
</DetailUtility>
|
||||
|
||||
<div class="recipe-read-left">
|
||||
<header class="entity-detail-header">
|
||||
<div>
|
||||
{editing ? (
|
||||
<input class="editable-entity-title" name="title" value={recipe.title} form="recipe-details-form" aria-label="Recipe name" required/>
|
||||
) : (
|
||||
<h1>{recipe.title}</h1>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{editing && (
|
||||
<section class="recipe-overview-strip">
|
||||
<form method="post" id="recipe-details-form" class="editor-form recipe-overview-form">
|
||||
<input type="hidden" name="save_version" value={recipe.save_version} />
|
||||
{saved && <div class="success-notice">Changes saved.</div>}
|
||||
{error && <div class="notice">{error}</div>}
|
||||
<div class:list={["inline-yield-editor",{"auto-calculated":autoYield}]}>
|
||||
<span class="yield-title-label">Total Yield</span>
|
||||
<div class="yield-inputs-row">
|
||||
<label><input name="yield_quantity" type="number" min="0.0001" step="any" required value={recipe.yield_quantity} placeholder="Qty" aria-label="Finished yield quantity" readonly={autoYield}/></label>
|
||||
<label><select name="yield_unit_id" aria-label="Finished yield unit" aria-disabled={autoYield}>{units.map((unit) => <option value={unit.id} selected={recipe.yield_unit_id === unit.id}>{unit.symbol}</option>)}</select></label>
|
||||
</div>
|
||||
<input type="hidden" name="yield_servings" value={recipe.yield_servings??""}/>
|
||||
<input type="hidden" name="yield_basis" value={recipe.yield_basis??""}/>
|
||||
</div>
|
||||
<input type="hidden" name="summary" value={recipe.summary??""}/>
|
||||
<input type="hidden" name="categories" value={categories}/>
|
||||
<input type="hidden" name="tags" value={tags}/>
|
||||
</form>
|
||||
<form method="post" class:list={["inline-auto-yield",{active:autoYield}]}>
|
||||
<input type="hidden" name="intent" value="auto_yield"/>
|
||||
<button class="toggle-button" aria-label={`${autoYield?"Disable":"Enable"} automatic total yield`}><i></i></button>
|
||||
<span>Auto calculate total yield</span>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<section id="structure" class="recipe-structure-workspace unified-recipe-editor">
|
||||
<RecipeStructureEditor client:load recipeId={recipe.id} initial={structure} ingredients={ingredientOptions} recipes={recipeOptions} units={units} prepActions={prepActionOptions} weightRates={weightRates} autoYield={autoYield} showMethod={true}/>
|
||||
</section>
|
||||
) : (
|
||||
<section class="recipe-view-formula-pane recipe-tab-panel active" data-recipe-panel="formula">
|
||||
<div class="recipe-view-formula">
|
||||
<RecipeCalculator client:load components={calculatorComponents} units={Object.fromEntries(unitMap)} basisUnitId={domainRecipe.scaling?.basis_amount?.unit_id??domainRecipe.yield.amount.unit_id} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnitId={domainRecipe.yield.amount.unit_id} yieldConversions={domainRecipe.measure_conversions??[]} mode={domainRecipe.scaling?.mode??"amount"} nutrition={nutrition} cost={cost} servings={domainRecipe.yield.servings} showDerived={false} showPercentControls={false}/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="recipe-read-right">
|
||||
<div class="recipe-workspace-tabs recipe-read-tabs">
|
||||
<button class="mobile-only-tab active" type="button" data-recipe-tab="formula"><Fragment set:html={recipeTabIcon("formula")}/><span class="recipe-tab-label">Ingredients</span></button>
|
||||
<button type="button" data-recipe-tab="method"><Fragment set:html={recipeTabIcon("method")}/><span class="recipe-tab-label">Prep Method</span></button>
|
||||
<button type="button" data-recipe-tab="costing"><Fragment set:html={recipeTabIcon("costing")}/><span class="recipe-tab-label">Cost</span></button>
|
||||
<button type="button" data-recipe-tab="equivalencies"><Fragment set:html={recipeTabIcon("equivalencies")}/><span class="recipe-tab-label">UoM Equivalency</span></button>
|
||||
<button type="button" data-recipe-tab="nutrition"><Fragment set:html={recipeTabIcon("nutrition")}/><span class="recipe-tab-label">Nutrition</span></button>
|
||||
</div>
|
||||
|
||||
<section class="recipe-view-details-pane">
|
||||
<div class="recipe-view-details">
|
||||
{editing ? (
|
||||
<section class="recipe-tab-panel active" data-recipe-panel="method">
|
||||
<div id="recipe-method-editor-slot"></div>
|
||||
<section id="additional" class="recipe-additional-editor">
|
||||
<h2>Additional details</h2>
|
||||
<form id="recipe-additional-form">
|
||||
<fieldset>
|
||||
<legend>Shelf Life</legend>
|
||||
<input name="shelf_quantity" type="number" min="0" step="any" value={shelfLife?.duration?.quantity??""} placeholder="Qty"/>
|
||||
<select name="shelf_unit">
|
||||
<option value="">Unit</option>
|
||||
{["hour","day","week","month"].map(unit=><option value={unit} selected={shelfLife?.duration?.unit_id===unit}>{unit}</option>)}
|
||||
</select>
|
||||
<input name="storage_condition" value={shelfLife?.storage_condition??""} placeholder="Storage condition"/>
|
||||
</fieldset>
|
||||
<label><span>Station</span><input name="station" value={additional.station??""} placeholder="Station Name"/></label>
|
||||
<label><span>Tags</span><input name="tags" value={tags} placeholder="Tag Name"/></label>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
) : (
|
||||
<section class="recipe-view-method recipe-tab-panel active" data-recipe-panel="method">
|
||||
<h2>Prep Method <small>{domainRecipe.steps.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>)}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section class="recipe-tab-panel recipe-view-equivalencies" data-recipe-panel="equivalencies">
|
||||
<h2>U of M Equivalency</h2>
|
||||
<p class="uom-equation-title">XX Weight = XX Volume = XX Each</p>
|
||||
<p class="uom-helper-text">
|
||||
If you would like to use this recipe by weight, volume, and even by the portion - you can customize that here.
|
||||
<button class="uom-help-btn" type="button" aria-label="Help on UoM Equivalency">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 16h-2v-2h2v2zm1.07-7.75l-.9.92C12.45 11.9 12 12.5 12 14h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H7c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.04-.42 1.99-1.07 2.75z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<div class="uom-toggle-row">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="standard-conversion-toggle" checked />
|
||||
<i></i>
|
||||
</label>
|
||||
<div class="toggle-label-group">
|
||||
<strong>Standard Weight - Volume Conversion</strong>
|
||||
<small>When toggled on, conversions are locked to 8oz = 1 cup. Toggle off to customize.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uom-equation-row">
|
||||
<!-- Weight Group -->
|
||||
<div class="uom-equation-group">
|
||||
<span class="uom-group-label">Weight</span>
|
||||
<div class="uom-box-pair">
|
||||
<input
|
||||
type="number"
|
||||
id="uom-mass-qty"
|
||||
name="mass_conversion"
|
||||
class="uom-qty-input"
|
||||
value={domainRecipe.yield.amount.quantity || 95}
|
||||
placeholder="1"
|
||||
step="any"
|
||||
min="0.0001"
|
||||
aria-label="Weight quantity"
|
||||
/>
|
||||
<select id="uom-mass-unit" name="mass_unit" class="uom-unit-select" aria-label="Weight unit">
|
||||
{units.filter(u => u.dimension === "mass" || (!u.dimension && ["gram", "g", "oz", "ounce", "lb", "pound", "kg"].includes(u.id))).map(u => (
|
||||
<option value={u.id} selected={u.id === (domainRecipe.yield.amount.unit_id || "gram") || u.id === "gram" || u.symbol === "g"}>
|
||||
{u.symbol || u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equals Sign -->
|
||||
<div class="uom-equation-separator">=</div>
|
||||
|
||||
<!-- Volume Group -->
|
||||
<div class="uom-equation-group">
|
||||
<span class="uom-group-label">Volume</span>
|
||||
<div class="uom-box-pair">
|
||||
<input
|
||||
type="number"
|
||||
id="uom-volume-qty"
|
||||
name="volume_conversion"
|
||||
class="uom-qty-input"
|
||||
value="1"
|
||||
placeholder="1"
|
||||
step="any"
|
||||
min="0.0001"
|
||||
aria-label="Volume quantity"
|
||||
/>
|
||||
<select id="uom-volume-unit" name="volume_unit" class="uom-unit-select" aria-label="Volume unit">
|
||||
{units.filter(u => u.dimension === "volume" || (!u.dimension && ["cup", "fl_oz", "tbsp", "tsp", "ml", "liter", "l", "gallon", "quart", "pint"].includes(u.id))).map(u => (
|
||||
<option value={u.id} selected={u.id === "cup" || u.symbol === "cup"}>
|
||||
{u.symbol || u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equals Sign -->
|
||||
<div class="uom-equation-separator">=</div>
|
||||
|
||||
<!-- Each Group -->
|
||||
<div class="uom-equation-group">
|
||||
<span class="uom-group-label">Each</span>
|
||||
<div class="uom-box-pair">
|
||||
<input
|
||||
type="number"
|
||||
id="uom-container-qty"
|
||||
name="container_conversion"
|
||||
class="uom-qty-input"
|
||||
value={domainRecipe.yield.servings || 1}
|
||||
placeholder="1"
|
||||
step="any"
|
||||
min="0.0001"
|
||||
aria-label="Each quantity"
|
||||
/>
|
||||
<select id="uom-container-unit" name="container_unit" class="uom-unit-select" aria-label="Each unit">
|
||||
<option value="serving" selected>serving</option>
|
||||
<option value="portion">portion</option>
|
||||
<option value="each">each</option>
|
||||
<option value="ea.">ea.</option>
|
||||
{units.filter(u => u.dimension === "count").map(u => (
|
||||
<option value={u.id}>{u.symbol || u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recipeConversions.length > 0 && (
|
||||
<div class="uom-custom-conversions">
|
||||
<h3>Custom Equivalencies</h3>
|
||||
{recipeConversions.map(x => (
|
||||
<div class="uom-custom-row">
|
||||
<strong>{x.from_quantity} {unitMap.get(x.from_unit_id)?.symbol ?? x.from_unit_id}</strong>
|
||||
<span>=</span>
|
||||
<strong>{x.to_quantity} {unitMap.get(x.to_unit_id)?.symbol ?? x.to_unit_id}</strong>
|
||||
{x.notes && <small>{x.notes}</small>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="costing">
|
||||
<LiveCostValues client:load cost={cost} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnit={unitMap.get(domainRecipe.yield.amount.unit_id)?.symbol??domainRecipe.yield.amount.unit_id} editable={editing}/>
|
||||
</section>
|
||||
|
||||
<section class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="nutrition">
|
||||
<LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients} editable={editing} saveVersion={recipe.save_version}/>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!editing && (
|
||||
<section class="recipe-additional-view">
|
||||
<div>
|
||||
<h2>Additional details</h2>
|
||||
{additional.station&&<p><strong>Station</strong><span>{additional.station}</span></p>}
|
||||
{shelfLife&&<p><strong>Shelf life</strong><span>{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}</span></p>}
|
||||
{JSON.parse(additional.notes_json??"[]").length>0&&<ul>{JSON.parse(additional.notes_json).map((note:string)=><li>{note}</li>)}</ul>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{!editing&&<script is:inline>document.querySelectorAll('[data-recipe-tab]').forEach(button=>button.addEventListener('click',()=>{document.querySelectorAll('[data-recipe-tab]').forEach(x=>x.classList.remove('active'));document.querySelectorAll('[data-recipe-panel]').forEach(x=>x.classList.remove('active'));button.classList.add('active');document.querySelector(`[data-recipe-panel="${button.dataset.recipeTab}"]`)?.classList.add('active');}));const recipeHash=location.hash.slice(1);if(recipeHash)document.querySelector(`[data-recipe-tab="${recipeHash}"]`)?.click();</script>}
|
||||
<script is:inline>
|
||||
function switchRecipeTab(tabName) {
|
||||
document.querySelectorAll('[data-recipe-tab]').forEach(x=>x.classList.toggle('active', x.dataset.recipeTab === tabName));
|
||||
document.querySelectorAll('[data-recipe-panel]').forEach(x=>x.classList.toggle('active', x.dataset.recipePanel === tabName));
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
}
|
||||
document.querySelectorAll('[data-recipe-tab]').forEach(button=>button.addEventListener('click',()=>{
|
||||
switchRecipeTab(button.dataset.recipeTab);
|
||||
}));
|
||||
const recipeHash=location.hash.slice(1);
|
||||
if(recipeHash) {
|
||||
const targetTab = document.querySelector(`[data-recipe-tab="${recipeHash}"]`);
|
||||
if (targetTab) {
|
||||
targetTab.click();
|
||||
}
|
||||
} else if (window.innerWidth <= 900) {
|
||||
switchRecipeTab('formula');
|
||||
}
|
||||
</script>
|
||||
{editing&&<script is:inline>
|
||||
let recipeDirty=false;
|
||||
const setRecipeDirty=(value=true)=>{recipeDirty=value;document.querySelector('#recipe-done')?.classList.toggle('dirty',value)};
|
||||
const setupTagEditor=()=>{const source=document.querySelector('#recipe-additional-form input[name="tags"]');if(!source||source.dataset.enhanced)return;source.dataset.enhanced='1';source.type='hidden';const editor=document.createElement('div'),chips=document.createElement('div'),entry=document.createElement('input');editor.className='tag-chip-editor';chips.className='tag-chip-list';entry.className='tag-chip-entry';entry.placeholder='Tag Name';let tags=source.value.split(',').map(value=>value.trim()).filter(Boolean);const render=()=>{chips.replaceChildren(...tags.map(tag=>{const chip=document.createElement('span'),label=document.createElement('b'),remove=document.createElement('button');label.textContent=tag;remove.type='button';remove.textContent='×';remove.ariaLabel=`Remove ${tag}`;remove.onclick=()=>{tags=tags.filter(value=>value!==tag);source.value=tags.join(', ');render();setRecipeDirty()};chip.append(label,remove);return chip}));source.value=tags.join(', ')};const add=()=>{const tag=entry.value.trim().replace(/^#+/,'');if(tag&&!tags.some(value=>value.toLowerCase()===tag.toLowerCase())){tags.push(tag);setRecipeDirty()}entry.value='';render()};entry.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===','){event.preventDefault();add()}else if(event.key==='Backspace'&&!entry.value&&tags.length){tags.pop();render();setRecipeDirty()}});entry.addEventListener('blur',add);source.after(editor);editor.append(chips,entry);render()};
|
||||
const setupRecipeEditor=()=>{const method=document.querySelector('.method-editor');if(!method||method.dataset.tabsReady)return;method.dataset.tabsReady='1';const coverSlot=method.querySelector('#recipe-cover-slot'),additionalSlot=method.querySelector('#recipe-additional-slot'),panelSlot=method.querySelector('#recipe-tab-panel-slot');const prepChildren=[method.querySelector(':scope > h2'),method.querySelector(':scope > ol'),method.querySelector(':scope > button')].filter(Boolean);const panels=[...document.querySelectorAll('[data-edit-recipe-panel]')];const additional=document.querySelector('#additional');const cover=additional?.querySelector('.cover-media-field');if(cover&&coverSlot)coverSlot.append(cover);panels.forEach(panel=>{panel.hidden=true;panelSlot?.append(panel)});if(additional&&additionalSlot)additionalSlot.append(additional);document.querySelectorAll('[data-edit-recipe-tab]').forEach(button=>button.addEventListener('click',()=>{const selected=button.dataset.editRecipeTab;document.querySelectorAll('[data-edit-recipe-tab]').forEach(tab=>tab.classList.toggle('active',tab===button));prepChildren.forEach(child=>child.hidden=selected!=='method');if(coverSlot)coverSlot.hidden=selected!=='method';if(additionalSlot)additionalSlot.hidden=selected!=='method';if(panelSlot)panelSlot.hidden=selected==='method';panels.forEach(panel=>panel.hidden=panel.dataset.editRecipePanel!==selected)}));};
|
||||
document.addEventListener('recipe:editor-ready',setupRecipeEditor);
|
||||
document.addEventListener('recipe:dirty',event=>setRecipeDirty(Boolean(event.detail)));
|
||||
document.querySelector('#recipe-details-form')?.addEventListener('input',()=>setRecipeDirty());
|
||||
document.querySelector('#recipe-additional-form')?.addEventListener('input',()=>setRecipeDirty());
|
||||
document.querySelector('#recipe-details-form')?.addEventListener('submit',event=>event.preventDefault());
|
||||
window.addEventListener('beforeunload',event=>{if(recipeDirty)event.preventDefault()});
|
||||
setTimeout(()=>{setupRecipeEditor();setupTagEditor()},0);
|
||||
setTimeout(()=>setupTagEditor(),0);
|
||||
document.querySelector('#recipe-done')?.addEventListener('click',async(event)=>{const button=event.currentTarget;button.disabled=true;button.textContent='Saving…';const saved=await new Promise(resolve=>document.dispatchEvent(new CustomEvent('recipe:save-structure',{detail:{complete:resolve}})));if(saved){recipeDirty=false;location.href=button.dataset.viewUrl}else{button.disabled=false;button.textContent='✓ Done';}});
|
||||
</script>}
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,9 +1,57 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
import fs from "node:fs"; import path from "node:path"; import YAML from "yaml";
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro"; import PurchasingReview from "../../../components/PurchasingReview";
|
||||
import { ingredients } from "../../../lib/data";
|
||||
const file=path.resolve(process.cwd(),"generated/receipt-product-candidates.yaml"); const data=fs.existsSync(file)?YAML.parse(fs.readFileSync(file,"utf8")):null;
|
||||
const ingredientOptions=[...ingredients.values()].map(({id,name})=>({id,name})).sort((a,b)=>a.name.localeCompare(b.name));
|
||||
export const prerender = false;
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import DetailUtility from "../../../components/DetailUtility.astro";
|
||||
import PurchasingReview from "../../../components/PurchasingReview";
|
||||
import { openDatabase } from "../../../lib/database";
|
||||
import { titleCase } from "../../../lib/format";
|
||||
|
||||
const file = path.resolve(process.cwd(), "generated/receipt-product-candidates.yaml");
|
||||
const data = fs.existsSync(file) ? YAML.parse(fs.readFileSync(file, "utf8")) : null;
|
||||
|
||||
const database = openDatabase();
|
||||
const ingredientOptions = database
|
||||
? (database.prepare("SELECT id, name FROM ingredients WHERE status = 'active' ORDER BY name").all() as Array<{ id: string; name: string }>).map((i) => ({ id: i.id, name: titleCase(i.name) }))
|
||||
: [];
|
||||
if (database) database.close();
|
||||
---
|
||||
<BaseLayout title="Purchasing review"><section class="shell page-heading"><p class="eyebrow">Local data tool</p><h1>Receipt product review</h1><p>Link actual Walmart and Sam's Club products to canonical ingredients.</p></section><section class="shell section-block">{data?<PurchasingReview client:load products={data.products} ingredients={ingredientOptions}/>:<div class="notice">Run <code>scripts/receipt-products propose</code>, then rebuild.</div>}</section></BaseLayout>
|
||||
|
||||
<BaseLayout title="Purchasing review" immersive>
|
||||
<div class="purchasing-review-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="purchasing-review-workspace">
|
||||
<header class="purchasing-review-header">
|
||||
<nav class="purchasing-breadcrumbs">
|
||||
<a href="/app/">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>All items</span>
|
||||
</a>
|
||||
</nav>
|
||||
<h1>Receipt product review</h1>
|
||||
<p class="purchasing-subtitle">Link Walmart and Sam's Club purchase products to canonical formulation ingredients.</p>
|
||||
</header>
|
||||
|
||||
{data ? (
|
||||
<PurchasingReview client:load products={data.products} ingredients={ingredientOptions} />
|
||||
) : (
|
||||
<div class="purchasing-notice-card">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
<div>
|
||||
<strong>No candidates generated yet</strong>
|
||||
<p>Run <code>scripts/receipt-products propose</code> to parse receipt files and extract product candidates.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -2,27 +2,93 @@
|
||||
interface Props { section?: string; sectionHref?: string }
|
||||
import { readOnlyMode } from "../lib/runtime";
|
||||
const { section, sectionHref } = Astro.props;
|
||||
const inferred = Astro.url.pathname.includes("/ingredients/")
|
||||
? { label:"Ingredients", href:"/app/?type=ingredient" }
|
||||
: Astro.url.pathname.includes("/recipe-books/")
|
||||
? { label:"Recipe Books", href:"/app/?type=book" }
|
||||
: Astro.url.pathname.includes("/recipes/")
|
||||
? { label:"Recipes", href:"/app/?type=recipe" }
|
||||
: { label:undefined, href:"/app/" };
|
||||
const resolvedSection=section??inferred.label;
|
||||
const resolvedHref=sectionHref??inferred.href;
|
||||
const pathname = Astro.url.pathname.replace(/\/+$/, "") + "/";
|
||||
|
||||
let inferredLabel: string | undefined;
|
||||
let inferredHref = "/app/";
|
||||
|
||||
if (pathname.startsWith("/app/inventory/")) {
|
||||
if (pathname === "/app/inventory/") {
|
||||
inferredLabel = "Inventory";
|
||||
inferredHref = "/app/";
|
||||
} else {
|
||||
inferredLabel = "Inventory";
|
||||
inferredHref = "/app/inventory/";
|
||||
}
|
||||
} else if (pathname.startsWith("/app/ingredients/")) {
|
||||
inferredLabel = "Ingredients";
|
||||
inferredHref = "/app/?type=ingredient";
|
||||
} else if (pathname.startsWith("/app/recipe-books/")) {
|
||||
inferredLabel = "Recipe Books";
|
||||
inferredHref = "/app/?type=book";
|
||||
} else if (pathname.startsWith("/app/recipes/")) {
|
||||
inferredLabel = "Recipes";
|
||||
inferredHref = "/app/?type=recipe";
|
||||
} else if (pathname.startsWith("/app/archive")) {
|
||||
inferredLabel = "Archive";
|
||||
inferredHref = "/app/";
|
||||
} else if (pathname.startsWith("/tools/purchasing-review")) {
|
||||
inferredLabel = "Purchasing Review";
|
||||
inferredHref = "/app/";
|
||||
}
|
||||
|
||||
const resolvedSection = section ?? inferredLabel;
|
||||
const resolvedHref = sectionHref ?? inferredHref;
|
||||
---
|
||||
<nav class="detail-utility" aria-label="Entity navigation">
|
||||
<div class="detail-context">
|
||||
<a class="detail-back" href={resolvedHref} aria-label={`Back to ${resolvedSection??"home"}`}>‹</a>
|
||||
<a class="detail-back" href={resolvedHref} aria-label={`Back to ${resolvedSection??"home"}`} data-back-link>‹</a>
|
||||
<a class="detail-avatar" href="/app/" aria-label="Recipe Book home">RB</a>
|
||||
<ol class="detail-breadcrumbs">
|
||||
<li><a href="/app/">Home</a></li>
|
||||
{resolvedSection&&<li aria-current="page"><a href={resolvedHref}>{resolvedSection}</a></li>}
|
||||
</ol>
|
||||
<slot />
|
||||
</div>
|
||||
<div class="detail-tools">
|
||||
<form action="/app/" role="search"><input name="q" type="search" placeholder="Search" aria-label="Search all items"/></form>
|
||||
{!readOnlyMode&&<details class="detail-new-menu"><summary><span class="new-trigger-plus" aria-hidden="true">+</span><span>New</span></summary><div><a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true">▦</span><strong>Recipe</strong></a><a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true">▣</span><strong>Recipe book</strong></a></div></details>}
|
||||
<form action="/app/" role="search" class="detail-search-form">
|
||||
<span class="search-input-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
</span>
|
||||
<input name="q" type="text" placeholder="Search" aria-label="Search all items" class="search-field" autocomplete="off"/>
|
||||
<button type="button" class="search-clear-btn" aria-label="Clear search" title="Clear search">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{!readOnlyMode&&<details class="detail-new-menu">
|
||||
<summary><span class="new-trigger-plus" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg></span><span>New</span></summary>
|
||||
<div>
|
||||
<a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z" style="transform: scale(1.1, 1.1) translate(4px, 3.5px);"/></svg></span><strong>Recipe</strong></a>
|
||||
<a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765C2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z" style="transform: scale(1.1, 1.1) translate(3.5px, 2.5px);"/></svg></span><strong>Recipe book</strong></a>
|
||||
</div>
|
||||
</details>}
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
document.querySelectorAll('.detail-search-form').forEach((formEl) => {
|
||||
const form = formEl as HTMLFormElement;
|
||||
const input = form.querySelector('.search-field') as HTMLInputElement | null;
|
||||
const clearBtn = form.querySelector('.search-clear-btn') as HTMLButtonElement | null;
|
||||
if (!input || !clearBtn) return;
|
||||
const sync = () => {
|
||||
if (input.value.trim().length > 0) form.classList.add('has-value');
|
||||
else form.classList.remove('has-value');
|
||||
};
|
||||
input.addEventListener('input', sync);
|
||||
clearBtn.addEventListener('click', () => {
|
||||
input.value = '';
|
||||
sync();
|
||||
input.focus();
|
||||
});
|
||||
sync();
|
||||
});
|
||||
|
||||
document.querySelectorAll('.detail-back[data-back-link]').forEach((link) => {
|
||||
link.addEventListener('click', (e) => {
|
||||
if (window.history.length > 1 && document.referrer && new URL(document.referrer, window.location.origin).origin === window.location.origin) {
|
||||
e.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { useRef,useState } from "preact/hooks";
|
||||
|
||||
export type DirectoryRow = {
|
||||
id:string; name:string; href?:string; kind:"recipe"|"ingredient"|"book"|"purchase"; icon:string;
|
||||
id:string; name:string; href?:string; kind:"recipe"|"ingredient"|"book"|"purchase"; detail?:string;
|
||||
};
|
||||
type Props={ rows:DirectoryRow[]; entityType:DirectoryRow["kind"]; emptyMessage:string; readOnly?:boolean };
|
||||
const TYPE_ICONS:Record<DirectoryRow["kind"],string> = {
|
||||
recipe:"M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z",
|
||||
ingredient:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z",
|
||||
book:"M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765 2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z",
|
||||
purchase:"M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z"
|
||||
};
|
||||
const TYPE_ICON_TRANSFORMS:Record<DirectoryRow["kind"],string|undefined> = {
|
||||
recipe:"scale(1.1, 1.1) translate(4px, 3.5px)",
|
||||
ingredient:"scale(1.3, 1.3) translate(2.5px, 1px)",
|
||||
book:"scale(1.1, 1.1) translate(3.5px, 2.5px)",
|
||||
purchase:undefined
|
||||
};
|
||||
type Props={ rows:DirectoryRow[]; entityType?:DirectoryRow["kind"]; emptyMessage:string; readOnly?:boolean };
|
||||
|
||||
export default function EntityDirectory({rows,entityType,emptyMessage,readOnly=false}:Props) {
|
||||
const [selected,setSelected]=useState<string[]>([]),[deleting,setDeleting]=useState(false),[error,setError]=useState("");
|
||||
@@ -12,33 +24,59 @@ export default function EntityDirectory({rows,entityType,emptyMessage,readOnly=f
|
||||
const allSelected=rows.length>0&&selected.length===rows.length;
|
||||
const toggle=(id:string)=>setSelected(current=>current.includes(id)?current.filter(value=>value!==id):[...current,id]);
|
||||
const requestDelete=(ids:string[])=>{if(!ids.length)return;setPendingDelete(ids);dialog.current?.showModal();};
|
||||
const kindById=new Map(rows.map(row=>[row.id,row.kind]));
|
||||
const remove=async()=>{
|
||||
const ids=pendingDelete;
|
||||
if(!ids.length)return;
|
||||
setDeleting(true);setError("");
|
||||
const response=await fetch("/api/app/entities/delete",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({type:entityType,ids})});
|
||||
const result=await response.json();
|
||||
if(!response.ok){setError(result.error??"Unable to delete selection.");setDeleting(false);dialog.current?.close();return;}
|
||||
const groups=new Map<DirectoryRow["kind"],string[]>();
|
||||
for(const id of ids){const kind=kindById.get(id)??entityType??"recipe";if(!groups.has(kind))groups.set(kind,[]);groups.get(kind)!.push(id);}
|
||||
try{
|
||||
for(const [kind,groupIds] of groups){
|
||||
const response=await fetch("/api/app/entities/delete",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({type:kind,ids:groupIds})});
|
||||
const result=await response.json();
|
||||
if(!response.ok){setError(result.error??"Unable to delete selection.");dialog.current?.close();return;}
|
||||
}
|
||||
} finally { setDeleting(false); }
|
||||
location.reload();
|
||||
};
|
||||
return <section class={`entity-directory-table${readOnly?" read-only":""}`}>
|
||||
{!readOnly&&<div class="entity-directory-toolbar">
|
||||
<input aria-label={`Select all ${entityType} items`} type="checkbox" checked={allSelected} ref={input=>{if(input)input.indeterminate=selected.length>0&&!allSelected;}} onChange={()=>setSelected(allSelected?[]:rows.map(row=>row.id))}/>
|
||||
<strong>{selected.length?`${selected.length} selected`:""}</strong>
|
||||
{selected.length>0&&<><button class="bulk-delete" type="button" disabled={deleting} onClick={()=>requestDelete(selected)}>⌫ Delete</button><button type="button" onClick={()=>setSelected([])}>Clear</button></>}
|
||||
{!readOnly&&<div class={`entity-directory-toolbar entity-directory-head${selected.length>0?" has-selection":""}`}>
|
||||
<input aria-label={`Select all ${entityType??"items"}`} type="checkbox" checked={allSelected} ref={input=>{if(input)input.indeterminate=selected.length>0&&!allSelected;}} onChange={()=>setSelected(allSelected?[]:rows.map(row=>row.id))}/>
|
||||
{selected.length>0 ? (
|
||||
<div class="entity-directory-selection-bar">
|
||||
<strong class="selection-count">{selected.length} Selected</strong>
|
||||
<div class="entity-directory-toolbar-actions">
|
||||
<button class="bulk-action-btn bulk-delete" type="button" disabled={deleting} onClick={()=>requestDelete(selected)} title="Delete selected" aria-label="Delete selected">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
<button class="bulk-action-btn bulk-clear" type="button" onClick={()=>setSelected([])} title="Clear selection" aria-label="Clear selection">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span>Type</span>
|
||||
<span>Name</span>
|
||||
<div class="entity-directory-toolbar-actions"></div>
|
||||
</>
|
||||
)}
|
||||
</div>}
|
||||
{error&&<p class="directory-error">{error}</p>}
|
||||
<div>{rows.map(row=><div class={`entity-directory-row${selected.includes(row.id)?" selected":""}`}>
|
||||
{!readOnly&&<input aria-label={`Select ${row.name}`} type="checkbox" checked={selected.includes(row.id)} onChange={()=>toggle(row.id)}/>}
|
||||
<span class={`workspace-pill-icon ${row.kind}`}>{row.icon}</span>
|
||||
<span class="entity-directory-name">{row.href?<a href={row.href}><strong>{row.name}</strong></a>:<strong>{row.name}</strong>}</span>
|
||||
{!readOnly&&<details class="entity-row-actions"><summary aria-label={`Actions for ${row.name}`}>⋮</summary><div><button type="button" onClick={()=>requestDelete([row.id])}>Delete</button></div></details>}
|
||||
<span class={`workspace-pill-icon ${row.kind}`}><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d={TYPE_ICONS[row.kind]} style={TYPE_ICON_TRANSFORMS[row.kind]?{transform:TYPE_ICON_TRANSFORMS[row.kind]}:undefined}/></svg></span>
|
||||
<span class="entity-directory-name">{row.href?<a href={row.href}><strong>{row.name}</strong>{row.detail&&<small>{row.detail}</small>}</a>:<><strong>{row.name}</strong>{row.detail&&<small>{row.detail}</small>}</>}</span>
|
||||
{!readOnly&&<details class="entity-row-actions"><summary aria-label={`Actions for ${row.name}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg></summary><div><button type="button" onClick={()=>requestDelete([row.id])}>Delete</button></div></details>}
|
||||
</div>)}</div>
|
||||
{rows.length===0&&<div class="empty-state">{emptyMessage}</div>}
|
||||
{!readOnly&&<dialog class="delete-confirmation" ref={dialog} onClose={()=>{if(!deleting)setPendingDelete([]);}}>
|
||||
<form method="dialog"><button class="dialog-close" aria-label="Close">×</button></form>
|
||||
<h2>Delete {pendingDelete.length===1?"item":`${pendingDelete.length} items`}?</h2>
|
||||
<p>This permanently removes the selected {pendingDelete.length===1?entityType:`${entityType} items`}. This action cannot be undone.</p>
|
||||
<p>This permanently removes the selected {pendingDelete.length===1?(entityType??"item"):`${pendingDelete.length} ${entityType??"items"}`}. This action cannot be undone.</p>
|
||||
<div><form method="dialog"><button disabled={deleting}>Cancel</button></form><button class="confirm-delete" type="button" disabled={deleting} onClick={remove}>{deleting?"Deleting…":"Delete"}</button></div>
|
||||
</dialog>}
|
||||
</section>;
|
||||
|
||||
@@ -1,15 +1,292 @@
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
type Candidate={ingredient_id:string;name:string;score:number};
|
||||
type Product={supplier_id:string;supplier_sku:string;name:string;url?:string;package?:{quantity:number;unit_id:string};prices:Array<{amount:number;effective_at:string}>;ingredient_candidates:Candidate[]};
|
||||
type Props={products:Product[];ingredients:Array<{id:string;name:string}>}; type Decisions=Record<string,string|null>;
|
||||
const STORAGE_KEY="recipe-book-purchasing-decisions-v1";
|
||||
export default function PurchasingReview({products,ingredients}:Props){
|
||||
const [decisions,setDecisions]=useState<Decisions>({}); const [query,setQuery]=useState(""); const [unresolved,setUnresolved]=useState(true);
|
||||
useEffect(()=>{try{setDecisions(JSON.parse(localStorage.getItem(STORAGE_KEY)??"{}"))}catch{}},[]);
|
||||
const choose=(key:string,value:string|null)=>{const next={...decisions,[key]:value};setDecisions(next);localStorage.setItem(STORAGE_KEY,JSON.stringify(next))};
|
||||
const visible=useMemo(()=>products.filter(p=>{const key=`${p.supplier_id}:${p.supplier_sku}`;return p.name.toLowerCase().includes(query.toLowerCase())&&(!unresolved||!(key in decisions))}),[products,query,unresolved,decisions]);
|
||||
const download=()=>{const url=URL.createObjectURL(new Blob([JSON.stringify({schema_version:1,generated_at:new Date().toISOString(),decisions},null,2)],{type:"application/json"}));const anchor=document.createElement("a");anchor.href=url;anchor.download="purchasing-decisions.json";anchor.click();URL.revokeObjectURL(url)};
|
||||
return <section><div class="review-toolbar"><div><strong>{Object.keys(decisions).length} / {products.length}</strong> reviewed<small>Products without explicit package sizes cannot be imported yet</small></div><input type="search" placeholder="Filter products" value={query} onInput={e=>setQuery(e.currentTarget.value)}/><label><input type="checkbox" checked={unresolved} onChange={e=>setUnresolved(e.currentTarget.checked)}/> Unresolved only</label><button onClick={download}>Export decisions</button></div>
|
||||
{visible.map(product=>{const key=`${product.supplier_id}:${product.supplier_sku}`;return <fieldset class="candidate-card" key={key}><legend>{product.name}</legend><p class="product-meta">{product.supplier_id.replace("_"," ")} · SKU {product.supplier_sku} · {product.package?`${product.package.quantity} ${product.package.unit_id}`:"package unknown"} · latest ${product.prices.at(-1)?.amount.toFixed(2)}</p>{product.ingredient_candidates.map(candidate=><label class="candidate-choice" key={candidate.ingredient_id}><input type="radio" name={key} checked={decisions[key]===candidate.ingredient_id} onChange={()=>choose(key,candidate.ingredient_id)}/><span><strong>{candidate.name}</strong><small>{candidate.ingredient_id} · {Math.round(candidate.score*100)}% token match</small></span></label>)}<label class="other-choice"><span>Choose another canonical ingredient</span><select value={decisions[key]??""} onChange={e=>choose(key,e.currentTarget.value||null)}><option value="">Select…</option>{ingredients.map(i=><option value={i.id}>{i.name} · {i.id}</option>)}</select></label><label class="candidate-choice none"><input type="radio" name={key} checked={key in decisions&&decisions[key]===null} onChange={()=>choose(key,null)}/><span><strong>Not a recipe ingredient</strong><small>Do not import this product</small></span></label>{product.url&&<p><a href={product.url} target="_blank" rel="noreferrer">Inspect product ↗</a></p>}</fieldset>})}
|
||||
</section>;
|
||||
|
||||
type Candidate = {
|
||||
ingredient_id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
type Product = {
|
||||
supplier_id: string;
|
||||
supplier_sku: string;
|
||||
name: string;
|
||||
url?: string;
|
||||
package?: {
|
||||
quantity: number;
|
||||
unit_id: string;
|
||||
};
|
||||
prices: Array<{
|
||||
amount: number;
|
||||
effective_at: string;
|
||||
}>;
|
||||
ingredient_candidates: Candidate[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
products: Product[];
|
||||
ingredients: Array<{ id: string; name: string }>;
|
||||
};
|
||||
|
||||
type Decisions = Record<string, string | null>;
|
||||
|
||||
const STORAGE_KEY = "recipe-book-purchasing-decisions-v1";
|
||||
|
||||
export default function PurchasingReview({ products, ingredients }: Props) {
|
||||
const [decisions, setDecisions] = useState<Decisions>({});
|
||||
const [query, setQuery] = useState("");
|
||||
const [unresolved, setUnresolved] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setDecisions(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const choose = (key: string, value: string | null) => {
|
||||
const next = { ...decisions, [key]: value };
|
||||
setDecisions(next);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
};
|
||||
|
||||
const visible = useMemo(() => {
|
||||
return products.filter((p) => {
|
||||
const key = `${p.supplier_id}:${p.supplier_sku}`;
|
||||
const matchesQuery = p.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
p.supplier_sku.toLowerCase().includes(query.toLowerCase());
|
||||
const matchesResolution = !unresolved || !(key in decisions);
|
||||
return matchesQuery && matchesResolution;
|
||||
});
|
||||
}, [products, query, unresolved, decisions]);
|
||||
|
||||
const reviewedCount = Object.keys(decisions).length;
|
||||
const progressPercent = products.length > 0 ? Math.round((reviewedCount / products.length) * 100) : 0;
|
||||
|
||||
const download = () => {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob(
|
||||
[
|
||||
JSON.stringify(
|
||||
{
|
||||
schema_version: 1,
|
||||
generated_at: new Date().toISOString(),
|
||||
decisions,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
],
|
||||
{ type: "application/json" }
|
||||
)
|
||||
);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = "purchasing-decisions.json";
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const supplierLabel = (id: string) => {
|
||||
if (id === "walmart") return "Walmart";
|
||||
if (id === "sams_club") return "Sam's Club";
|
||||
return id.replace("_", " ");
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="purchasing-review-container">
|
||||
<div class="purchasing-review-toolbar">
|
||||
<div class="purchasing-review-stats">
|
||||
<div class="stats-counter">
|
||||
<strong class="stats-count">{reviewedCount} / {products.length}</strong>
|
||||
<span class="stats-label">reviewed ({progressPercent}%)</span>
|
||||
</div>
|
||||
<small class="stats-hint">Products without explicit package sizes cannot be imported automatically.</small>
|
||||
</div>
|
||||
|
||||
<div class="purchasing-review-actions">
|
||||
<div class="purchasing-search-box">
|
||||
<svg class="search-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search products or SKU..."
|
||||
value={query}
|
||||
onInput={(e) => setQuery(e.currentTarget.value)}
|
||||
class="search-input"
|
||||
/>
|
||||
{query && (
|
||||
<button class="search-clear-btn" onClick={() => setQuery("")} title="Clear search">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label class="purchasing-filter-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={unresolved}
|
||||
onChange={(e) => setUnresolved(e.currentTarget.checked)}
|
||||
/>
|
||||
<span class="toggle-track">
|
||||
<i class="toggle-thumb" />
|
||||
</span>
|
||||
<span class="toggle-label">Unresolved only</span>
|
||||
</label>
|
||||
|
||||
<button onClick={download} class="purchasing-export-btn" title="Download JSON decisions file">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
<span>Export decisions</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visible.length > 0 ? (
|
||||
<div class="purchasing-products-grid">
|
||||
{visible.map((product) => {
|
||||
const key = `${product.supplier_id}:${product.supplier_sku}`;
|
||||
const currentDecision = decisions[key];
|
||||
const isResolved = key in decisions;
|
||||
const latestPrice = product.prices.at(-1)?.amount;
|
||||
|
||||
return (
|
||||
<article class={`purchasing-card ${isResolved ? "resolved" : ""}`} key={key}>
|
||||
<header class="purchasing-card-header">
|
||||
<div class="purchasing-card-title-group">
|
||||
<div class="purchasing-badges">
|
||||
<span class={`supplier-badge ${product.supplier_id}`}>
|
||||
{supplierLabel(product.supplier_id)}
|
||||
</span>
|
||||
<span class="sku-badge">SKU #{product.supplier_sku}</span>
|
||||
{product.package && (
|
||||
<span class="package-badge">
|
||||
{product.package.quantity} {product.package.unit_id.replace("_", " ")}
|
||||
</span>
|
||||
)}
|
||||
{latestPrice != null && (
|
||||
<span class="price-badge">${latestPrice.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 class="purchasing-product-name">{product.name}</h3>
|
||||
</div>
|
||||
|
||||
{product.url && (
|
||||
<a
|
||||
href={product.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="purchasing-external-link"
|
||||
title="Inspect product in new tab"
|
||||
>
|
||||
<span>Inspect product</span>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div class="purchasing-candidates-section">
|
||||
<span class="candidates-heading">Select ingredient mapping:</span>
|
||||
|
||||
<div class="candidate-options-list">
|
||||
{product.ingredient_candidates.map((candidate) => {
|
||||
const isSelected = currentDecision === candidate.ingredient_id;
|
||||
const matchPercent = Math.round(candidate.score * 100);
|
||||
|
||||
return (
|
||||
<label
|
||||
class={`candidate-option-card ${isSelected ? "selected" : ""}`}
|
||||
key={candidate.ingredient_id}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={key}
|
||||
checked={isSelected}
|
||||
onChange={() => choose(key, candidate.ingredient_id)}
|
||||
class="candidate-radio"
|
||||
/>
|
||||
<span class="candidate-custom-radio">
|
||||
<i />
|
||||
</span>
|
||||
<div class="candidate-info">
|
||||
<strong class="candidate-name">{candidate.name}</strong>
|
||||
<span class="candidate-meta">{candidate.ingredient_id}</span>
|
||||
</div>
|
||||
<span class={`candidate-score-badge ${matchPercent >= 80 ? "high" : ""}`}>
|
||||
{matchPercent}% match
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
<div class="candidate-other-option">
|
||||
<label class="other-select-label">
|
||||
<span class="other-select-text">Or choose another ingredient:</span>
|
||||
<select
|
||||
value={currentDecision && !product.ingredient_candidates.some((c) => c.ingredient_id === currentDecision) ? currentDecision : ""}
|
||||
onChange={(e) => choose(key, e.currentTarget.value || null)}
|
||||
class="purchasing-select"
|
||||
>
|
||||
<option value="">Select ingredient…</option>
|
||||
{ingredients.map((i) => (
|
||||
<option value={i.id} key={i.id}>
|
||||
{i.name} ({i.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class={`candidate-option-card none ${isResolved && currentDecision === null ? "selected" : ""}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name={key}
|
||||
checked={isResolved && currentDecision === null}
|
||||
onChange={() => choose(key, null)}
|
||||
class="candidate-radio"
|
||||
/>
|
||||
<span class="candidate-custom-radio">
|
||||
<i />
|
||||
</span>
|
||||
<div class="candidate-info">
|
||||
<strong class="candidate-name">Not a recipe ingredient</strong>
|
||||
<span class="candidate-meta">Ignore and do not import this product</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div class="purchasing-empty-state">
|
||||
<div class="empty-icon-circle">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 14 14"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>No products to review</h3>
|
||||
<p>
|
||||
{unresolved
|
||||
? "All candidate products have been reviewed! Uncheck 'Unresolved only' to inspect past decisions."
|
||||
: "No products matched your search query."}
|
||||
</p>
|
||||
{query && (
|
||||
<button class="purchasing-reset-btn" onClick={() => setQuery("")}>
|
||||
Clear search filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import type { CalculatorComponent, CalculatorItem, Unit } from "../lib/types";
|
||||
import { convert } from "../lib/measurement";
|
||||
import type { NutritionResult } from "../lib/nutrition";
|
||||
import type { CostResult } from "../lib/costing";
|
||||
import type { CostLine, CostResult } from "../lib/costing";
|
||||
import NutritionPanel from "./NutritionPanel";
|
||||
import { number, roundForDisplay } from "../lib/format";
|
||||
|
||||
@@ -17,6 +17,7 @@ type Props = {
|
||||
cost: CostResult;
|
||||
servings?: number;
|
||||
showDerived?: boolean;
|
||||
showPercentControls?: boolean;
|
||||
yieldConversions?: CalculatorItem["measureConversions"];
|
||||
};
|
||||
|
||||
@@ -46,9 +47,9 @@ function convertItem(quantity: number, fromUnitId: string, toUnitId: string, ite
|
||||
throw new Error(`No reviewed equivalency from ${fromUnitId} to ${toUnitId}`);
|
||||
}
|
||||
|
||||
export default function RecipeCalculator({ components, units, basisUnitId, yieldQuantity, yieldUnitId, nutrition, cost, servings, showDerived = true, yieldConversions = [] }: Props) {
|
||||
export default function RecipeCalculator({ components, units, basisUnitId, yieldQuantity, yieldUnitId, nutrition, cost, servings, showDerived = true, showPercentControls = true, yieldConversions = [] }: Props) {
|
||||
const [factor, setFactor] = useState(1);
|
||||
const [calculatePercent,setCalculatePercent]=useState(true);
|
||||
const [calculatePercent,setCalculatePercent]=useState(showPercentControls);
|
||||
const [percentMode,setPercentMode]=useState<"standard"|"bakers">("standard");
|
||||
const [yieldDisplayUnitId, setYieldDisplayUnitId] = useState(yieldUnitId);
|
||||
const [lineUnits, setLineUnits] = useState<Record<string, string>>({});
|
||||
@@ -77,29 +78,51 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
|
||||
return (
|
||||
<section class="calculator" aria-labelledby="formula-heading">
|
||||
<div class="calculator-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Scalable formula</p>
|
||||
<h2 id="formula-heading">Ingredients</h2>
|
||||
</div>
|
||||
<label class="basis-input">
|
||||
<span>Batch multiplier</span>
|
||||
<span class="input-with-unit recipe-scale-control batch-size-control">
|
||||
<label class="basis-input batch-multiplier-field">
|
||||
<span class="scale-label">Batch:</span>
|
||||
<span class="recipe-scale-control batch-size-control">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
aria-label="Batch multiplier"
|
||||
value={roundForDisplay(factor)}
|
||||
onInput={(event) => setFactor(Number((event.currentTarget as HTMLInputElement).value))}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<label class="basis-input">
|
||||
<span>Finished yield</span>
|
||||
<span class="quantity-control recipe-scale-control"><input type="number" min="0" step="any" value={roundForDisplay(scaledYield)} onInput={(event) => changeYield(Number(event.currentTarget.value))}/><select aria-label="Yield unit" value={yieldDisplayUnitId} onChange={(event) => setYieldDisplayUnitId(event.currentTarget.value)}>{yieldUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}</select></span>
|
||||
<label class="basis-input finished-yield-field">
|
||||
<span class="scale-label">Yield:</span>
|
||||
<span class="recipe-scale-control quantity-control">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
aria-label="Finished yield quantity"
|
||||
value={roundForDisplay(scaledYield)}
|
||||
onInput={(event) => changeYield(Number((event.currentTarget as HTMLInputElement).value))}
|
||||
/>
|
||||
<select aria-label="Finished yield unit" value={yieldDisplayUnitId} onChange={(event) => setYieldDisplayUnitId((event.currentTarget as HTMLSelectElement).value)}>
|
||||
{yieldUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
<div class="calculator-percent-controls">{calculatePercent&&<span class="percent-mode"><button class={percentMode==="standard"?"active":""} onClick={()=>setPercentMode("standard")}>Standard %</button><button class={percentMode==="bakers"?"active":""} onClick={()=>setPercentMode("bakers")}>Baker's %</button></span>}<label class="calculate-toggle"><span>Calculate %</span><input type="checkbox" checked={calculatePercent} onChange={(event)=>setCalculatePercent(event.currentTarget.checked)}/><i></i></label></div>
|
||||
{showPercentControls && (
|
||||
<div class="calculator-percent-controls">
|
||||
{calculatePercent && (
|
||||
<span class="percent-mode">
|
||||
<button type="button" class={percentMode === "standard" ? "active" : ""} onClick={() => setPercentMode("standard")}>Standard %</button>
|
||||
<button type="button" class={percentMode === "bakers" ? "active" : ""} onClick={() => setPercentMode("bakers")}>Baker's %</button>
|
||||
</span>
|
||||
)}
|
||||
<label class="calculate-toggle">
|
||||
<span>Calculate %</span>
|
||||
<input type="checkbox" checked={calculatePercent} onChange={(event) => setCalculatePercent((event.currentTarget as HTMLInputElement).checked)} />
|
||||
<i></i>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p class="scale-relationship">1× produces {number(yieldQuantity)} {units[yieldUnitId]?.symbol??yieldUnitId} finished yield. Changing the multiplier, finished yield, or any ingredient amount scales the entire recipe.</p>
|
||||
|
||||
{calculatePercent&&percentMode==="bakers"&&baseItems.length>0&&<section class="bakers-base-summary"><strong>Base</strong><div>{baseItems.map(item=>{const displayUnitId=lineUnits[item.id]??item.amount.unit_id;const displayQuantity=convertItem(item.amount.quantity*validFactor,item.amount.unit_id,displayUnitId,item,units);return <p><span><input aria-label={`${item.label} base quantity`} type="number" min="0" step="any" value={roundForDisplay(displayQuantity)} onInput={(event)=>{const canonicalQuantity=convertItem(Number(event.currentTarget.value),displayUnitId,item.amount.unit_id,item,units);setFactor(item.amount.quantity>0?canonicalQuantity/item.amount.quantity:1)}}/><select aria-label={`${item.label} base unit`} value={displayUnitId} onChange={(event)=>setLineUnits((current)=>({...current,[item.id]:event.currentTarget.value}))}>{itemUnits(item,units).map(unit=><option value={unit.id}>{unit.symbol}</option>)}</select></span><a href={item.href}>{item.label}</a></p>})}</div></section>}
|
||||
{components.map((component) => (
|
||||
@@ -107,7 +130,7 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
|
||||
{components.length > 1 && <h3>{component.name}</h3>}
|
||||
<div class="table-wrap">
|
||||
<table class="recipe-ingredients">
|
||||
<thead><tr><th>Ingredient</th>{calculatePercent&&<th>{percentMode==="standard"?"Standard %":"Baker's %"}</th>}<th>Weight</th></tr></thead>
|
||||
<thead><tr><th>Amount</th><th>Ingredient</th>{calculatePercent&&<th>{percentMode==="standard"?"Standard %":"Baker's %"}</th>}</tr></thead>
|
||||
<tbody>
|
||||
{component.items.filter(item=>percentMode!=="bakers"||!calculatePercent||!item.basisMember).map((item) => {
|
||||
const displayUnitId = lineUnits[item.id] ?? item.amount.unit_id;
|
||||
@@ -119,13 +142,13 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
|
||||
};
|
||||
return (
|
||||
<tr key={item.id} class={item.basisMember ? "basis-row" : ""}>
|
||||
<td><span class="quantity-control line-quantity"><input aria-label={`${item.label} quantity`} type="number" min="0" step="any" value={roundForDisplay(displayQuantity)} onInput={(event) => changeLineQuantity(Number(event.currentTarget.value))}/><select aria-label={`${item.label} unit`} value={displayUnitId} style={{width:`${(compatibleUnits.find((unit) => unit.id === displayUnitId)?.symbol ?? displayUnitId).length + 0.75}ch`}} onChange={(event) => { const next = event.currentTarget.value; try { convertItem(item.amount.quantity * validFactor, item.amount.unit_id, next, item, units); } catch { event.currentTarget.value = displayUnitId; return; } setLineUnits((current) => ({ ...current, [item.id]: next })); }}>{compatibleUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}</select></span></td>
|
||||
<td>
|
||||
{item.href ? <a href={item.href}>{item.label}</a> : item.label}
|
||||
<span class="calculator-ingredient-name">{item.href ? <a href={item.href}>{item.label}</a> : item.label}{item.attention&&<span class="ingredient-attention-icon" role="img" aria-label={item.attentionMessage??"Needs attention"} title={item.attentionMessage??"Needs attention"}><svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><path fill="currentColor" d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg></span>}</span>
|
||||
{item.optional && <span class="muted"> optional</span>}
|
||||
{item.notes && <small>{item.notes}</small>}
|
||||
</td>
|
||||
{calculatePercent&&<td>{itemWeight(item)==null||percentageBase<=0?"—":`${number(itemWeight(item)!/percentageBase*100)}%`}</td>}
|
||||
<td><span class="quantity-control line-quantity"><input aria-label={`${item.label} quantity`} type="number" min="0" step="any" value={roundForDisplay(displayQuantity)} onInput={(event) => changeLineQuantity(Number(event.currentTarget.value))}/><select aria-label={`${item.label} unit`} value={displayUnitId} onChange={(event) => setLineUnits((current) => ({ ...current, [item.id]: event.currentTarget.value }))}>{compatibleUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}</select></span></td>
|
||||
{calculatePercent&&<td class="recipe-percent-cell">{itemWeight(item)==null||percentageBase<=0?"—":`${number(itemWeight(item)!/percentageBase*100)}%`}</td>}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -169,10 +192,121 @@ function useLiveFactor() {
|
||||
return factor;
|
||||
}
|
||||
|
||||
export function LiveCostValues({ cost }: { cost: CostResult }) {
|
||||
function CostLedgerLine({ line, factor, currency, editable, expanded }: { line:CostLine; factor:number; currency:string; editable:boolean; expanded:boolean }) {
|
||||
const money=new Intl.NumberFormat("en-US",{style:"currency",currency,minimumFractionDigits:2,maximumFractionDigits:4});
|
||||
const editHref = line.kind === "ingredient" ? `/app/ingredients/${line.subjectId}/?edit=1#costs` : `/app/recipes/${line.subjectId}/?edit=1#costing`;
|
||||
const viewHref = line.kind === "ingredient" ? `/app/ingredients/${line.subjectId}/#costs` : `/app/recipes/${line.subjectId}/#costing`;
|
||||
const hasChildren = Boolean(line.purchase || line.children?.length);
|
||||
const costValue = line.cost != null ? money.format(line.cost * factor) : "—";
|
||||
|
||||
const handleSummaryClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest("a") || target?.closest("button")) {
|
||||
return;
|
||||
}
|
||||
if (!hasChildren) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<details class={`cost-ledger-line ${hasChildren ? "has-children" : "flat"}`} open={expanded}>
|
||||
<summary onClick={handleSummaryClick}>
|
||||
<span class="cost-toggle-marker" aria-hidden="true">
|
||||
{hasChildren && (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" class="chevron-icon">
|
||||
<path fill="currentColor" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span class={`cost-subject-icon ${line.kind}`}>{line.kind==="recipe"?"R":"●"}</span>
|
||||
<a href={editable ? editHref : viewHref} class="cost-subject-name" onClick={(e) => e.stopPropagation()}>{line.name}</a>
|
||||
<span class="cost-attention-slot">
|
||||
{line.completeness < 1 && (
|
||||
<span class="cost-attention-badge" title="Cost information is incomplete (missing purchase item or price)" aria-label="Cost information is incomplete">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span class="cost-line-value">
|
||||
{line.kind === "ingredient" ? (
|
||||
<a href={editHref} class="cost-editable-link" title={line.cost != null ? "Edit purchase cost" : "Add purchase cost"} onClick={(e) => e.stopPropagation()}>
|
||||
<span>{line.cost != null ? costValue : "Add cost"}</span>
|
||||
<span class="cost-edit-icon" aria-hidden="true">✎</span>
|
||||
</a>
|
||||
) : (
|
||||
<span>{costValue}</span>
|
||||
)}
|
||||
</span>
|
||||
</summary>
|
||||
{hasChildren && (
|
||||
<div class="cost-line-detail">
|
||||
{line.purchase ? (
|
||||
<>
|
||||
<div><small>Purchase item name</small><strong>{line.purchase.name}</strong></div>
|
||||
<div><small>Purchase cost</small><strong>{money.format(line.purchase.price)}</strong></div>
|
||||
<div><small>Purchase unit</small><strong>{number(line.purchase.packageQuantity)} {line.purchase.packageUnitId}</strong></div>
|
||||
<div><small>Date added</small><strong>{line.purchase.effectiveAt}</strong></div>
|
||||
<div><small>Item ID #</small><strong>{line.purchase.sku??"—"}</strong></div>
|
||||
<div><small>Vendor</small><strong>{line.purchase.supplier??"—"}</strong></div>
|
||||
</>
|
||||
) : (
|
||||
<p>No usable purchase cost is available.</p>
|
||||
)}
|
||||
{line.children?.length ? (
|
||||
<div class="cost-child-lines">
|
||||
{line.children.map((child) => (
|
||||
<CostLedgerLine line={child} factor={factor} currency={currency} editable={editable} expanded={expanded}/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveCostValues({ cost, yieldQuantity, yieldUnit="g", editable=false }: { cost: CostResult; yieldQuantity?:number; yieldUnit?:string; editable?:boolean }) {
|
||||
const factor=useLiveFactor();
|
||||
const money=new Intl.NumberFormat("en-US",{style:"currency",currency:cost.currency,minimumFractionDigits:2,maximumFractionDigits:4});
|
||||
return <section class="derived-card"><div class="derived-title"><h2>Recipe Cost</h2><strong>{Math.round(cost.completeness*100)}% priced</strong></div>{cost.batch!=null?<dl><div><dt>Scaled batch</dt><dd>{money.format(cost.batch*factor)}</dd></div>{cost.perServing!=null&&<div><dt>Per serving</dt><dd>{money.format(cost.perServing*factor)}</dd></div>}{cost.per100g!=null&&<div><dt>Per 100 g</dt><dd>{money.format(cost.per100g)}</dd></div>}</dl>:<p>No usable purchase prices are available yet.</p>}{cost.completeness<1&&<p class="derived-warning">Partial estimate; unpriced ingredients are excluded.</p>}{cost.warnings.length>0&&<details class="cost-diagnostics"><summary>{cost.warnings.length} costing {cost.warnings.length===1?"issue":"issues"}</summary><ul>{cost.warnings.map(warning=><li>{warning}</li>)}</ul></details>}</section>;
|
||||
const [expansion,setExpansion]=useState({open:false,revision:0});
|
||||
const setAll=(open:boolean)=>setExpansion((current)=>({open,revision:current.revision+1}));
|
||||
|
||||
return (
|
||||
<section class="recipe-cost-ledger">
|
||||
<header>
|
||||
<h2>Recipe Cost</h2>
|
||||
<p>{editable?"Update an ingredient’s shared purchase cost here. The change is reflected in every recipe that uses it.":"Ingredient and sub-recipe costs used to calculate this recipe."}</p>
|
||||
</header>
|
||||
<div class="cost-ledger-heading">
|
||||
<span class="head-spacer" aria-hidden="true"></span>
|
||||
<span class="head-icon-spacer" aria-hidden="true"></span>
|
||||
<span class="head-subject">
|
||||
Ingredient / Sub-Recipe{" "}
|
||||
<button type="button" onClick={()=>setAll(true)}>Expand all</button>
|
||||
<i>|</i>
|
||||
<button type="button" onClick={()=>setAll(false)}>Collapse all</button>
|
||||
</span>
|
||||
<span class="head-attention-spacer" aria-hidden="true"></span>
|
||||
<span class="head-cost">Cost</span>
|
||||
</div>
|
||||
<div class={expansion.open?"cost-ledger-lines expand-all":"cost-ledger-lines"}>
|
||||
{cost.lines.map((line)=>(
|
||||
<CostLedgerLine key={`${line.id}:${expansion.revision}`} line={line} factor={factor} currency={cost.currency} editable={editable} expanded={expansion.open}/>
|
||||
))}
|
||||
</div>
|
||||
<div class="cost-summary">
|
||||
<div><strong>Total Yield</strong><span>{yieldQuantity!=null?number(yieldQuantity*factor):"—"} <small>{yieldUnit}</small></span></div>
|
||||
<div><strong>Total Cost</strong><span>{cost.batch!=null?money.format(cost.batch*factor):"—"}</span></div>
|
||||
<div><strong>Cost Per {yieldUnit.toUpperCase()}:</strong><span>{cost.batch!=null&&yieldQuantity?money.format(cost.batch/yieldQuantity):"—"}</span></div>
|
||||
{cost.perServing!=null&&<div><strong>Cost Per Serving</strong><span>{money.format(cost.perServing)}</span></div>}
|
||||
</div>
|
||||
{cost.completeness<1&&<p class="derived-warning">Partial estimate; unpriced ingredients are excluded. {Math.round(cost.completeness*100)}% of ingredient weight is priced.</p>}
|
||||
{cost.warnings.length>0&&<details class="cost-diagnostics"><summary>{cost.warnings.length} costing {cost.warnings.length===1?"issue":"issues"}</summary><ul>{cost.warnings.map(warning=><li>{warning}</li>)}</ul></details>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveNutritionValues({ nutrition,servings,ingredients=[],editable=false,saveVersion }: { nutrition:NutritionResult; servings?:number; ingredients?:import("./NutritionPanel").NutritionIngredientStatus[]; editable?:boolean; saveVersion?:number }) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
type Props = {
|
||||
open: boolean;
|
||||
text: string;
|
||||
error: string;
|
||||
parsing: boolean;
|
||||
onTextChange: (text: string) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export default function BulkIngredientImportModal({
|
||||
open,
|
||||
text,
|
||||
error,
|
||||
parsing,
|
||||
onTextChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
class="bulk-ingredient-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) onClose();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
class="bulk-ingredient-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bulk-ingredient-title"
|
||||
>
|
||||
<button
|
||||
class="bulk-dialog-close"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h2 id="bulk-ingredient-title">Add Ingredients</h2>
|
||||
<p>
|
||||
Type or copy/paste ingredients from a document, spreadsheet, PDF, or
|
||||
website.
|
||||
</p>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={text}
|
||||
onInput={(event) => onTextChange(event.currentTarget.value)}
|
||||
placeholder={
|
||||
"Dry Mix:\n500g flour\n1/2 cup semolina\nsalt to taste\n\nWet:\n5 cloves garlic\n3 egg yolks\nolive oil (room temp)"
|
||||
}
|
||||
/>
|
||||
{error && <p class="bulk-dialog-error">{error}</p>}
|
||||
<div class="bulk-entry-help">
|
||||
<span>
|
||||
Add headers <small>using a colon : eg To Garnish:</small>
|
||||
</span>
|
||||
<span>
|
||||
Add notes to ingredients <small>by putting them in (notes)</small>
|
||||
</span>
|
||||
</div>
|
||||
<footer>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-cancel"
|
||||
disabled={parsing}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-submit"
|
||||
disabled={!text.trim() || parsing}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{parsing ? "Parsing…" : "Add Ingredients"}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
type Props = {
|
||||
open: boolean;
|
||||
text: string;
|
||||
error: string;
|
||||
onTextChange: (text: string) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export default function BulkPrepStepsModal({
|
||||
open,
|
||||
text,
|
||||
error,
|
||||
onTextChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
class="bulk-ingredient-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) onClose();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
class="bulk-ingredient-dialog bulk-prep-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bulk-prep-title"
|
||||
>
|
||||
<button
|
||||
class="bulk-dialog-close"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h2 id="bulk-prep-title">Add Prep Steps</h2>
|
||||
<p>
|
||||
Type or copy/paste prep steps from a document, spreadsheet, PDF, or
|
||||
website.
|
||||
</p>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={text}
|
||||
onInput={(event) => onTextChange(event.currentTarget.value)}
|
||||
placeholder={
|
||||
'Cut & Portion Pork Belly:\n1. Dice pork belly into 1" cubes\n2. Transfer into container\n\nTo Marinate:\n3. Mix gochujang, mustard, and oil\n4. Coat pork thoroughly and refrigerate'
|
||||
}
|
||||
/>
|
||||
{error && <p class="bulk-dialog-error">{error}</p>}
|
||||
<div class="bulk-entry-help">
|
||||
<span>
|
||||
Add headers <small>using a colon : eg To Sear:</small>
|
||||
</span>
|
||||
<span>
|
||||
Add notes to prep method <small>by putting them in (notes)</small>
|
||||
</span>
|
||||
</div>
|
||||
<footer>
|
||||
<button type="button" class="bulk-cancel" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-submit"
|
||||
disabled={!text.trim()}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Add Prep Steps
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import type { RecipeStructure } from "../../lib/database";
|
||||
import type { DragState, Option, PercentMode } from "./types";
|
||||
|
||||
type Props = {
|
||||
item: RecipeStructure["components"][number]["items"][number];
|
||||
componentIndex: number;
|
||||
itemIndex: number;
|
||||
units: Array<Option & { symbol: string }>;
|
||||
ingredients: Option[];
|
||||
pendingIngredients: Option[];
|
||||
recipes: Option[];
|
||||
recipeId: string;
|
||||
dragging: DragState;
|
||||
calculatePercent: boolean;
|
||||
percentMode: PercentMode;
|
||||
percentValue: number | undefined;
|
||||
focusedItem: string | undefined;
|
||||
rowQueryValue: string | undefined;
|
||||
onUpdateQuantity: (quantity: number) => void;
|
||||
onUpdateUnit: (unitId: string) => void;
|
||||
onUpdateNotes: (notes: string) => void;
|
||||
onUpdateBasis: (basis: boolean) => void;
|
||||
onRemove: () => void;
|
||||
onDragStart: (event: DragEvent) => void;
|
||||
onDrop: (event: DragEvent) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onSelectChoice: (
|
||||
kind: "ingredient" | "recipe",
|
||||
id: string,
|
||||
name: string
|
||||
) => void;
|
||||
onCreatePendingIngredient: (name: string) => void;
|
||||
onSetPercent: (target: number) => void;
|
||||
};
|
||||
|
||||
const normal = (value: string) =>
|
||||
value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
|
||||
export default function RecipeItemRow({
|
||||
item,
|
||||
componentIndex,
|
||||
itemIndex,
|
||||
units,
|
||||
ingredients,
|
||||
pendingIngredients,
|
||||
recipes,
|
||||
recipeId,
|
||||
dragging,
|
||||
calculatePercent,
|
||||
percentMode,
|
||||
percentValue,
|
||||
focusedItem,
|
||||
rowQueryValue,
|
||||
onUpdateQuantity,
|
||||
onUpdateUnit,
|
||||
onUpdateNotes,
|
||||
onUpdateBasis,
|
||||
onRemove,
|
||||
onDragStart,
|
||||
onDrop,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onQueryChange,
|
||||
onSelectChoice,
|
||||
onCreatePendingIngredient,
|
||||
onSetPercent,
|
||||
}: Props) {
|
||||
const isPending = Boolean(
|
||||
item.ingredient_id &&
|
||||
pendingIngredients.some((entry) => entry.id === item.ingredient_id)
|
||||
);
|
||||
const label = item.ingredient_id
|
||||
? [...ingredients, ...pendingIngredients].find(
|
||||
(entry) => entry.id === item.ingredient_id
|
||||
)?.name
|
||||
: recipes.find((entry) => entry.id === item.subrecipe_id)?.name;
|
||||
const typedValue = rowQueryValue ?? label ?? "";
|
||||
const choices = [
|
||||
...[...ingredients, ...pendingIngredients].map((entry) => ({
|
||||
...entry,
|
||||
kind: "ingredient" as const,
|
||||
})),
|
||||
...recipes
|
||||
.filter((entry) => entry.id !== recipeId)
|
||||
.map((entry) => ({ ...entry, kind: "recipe" as const })),
|
||||
];
|
||||
const exactMatch = choices.some((entry) =>
|
||||
[entry.name, ...(entry.aliases ?? [])].some(
|
||||
(name) => normal(name) === normal(typedValue)
|
||||
)
|
||||
);
|
||||
const shownChoices = choices
|
||||
.filter(
|
||||
(entry) =>
|
||||
!typedValue.trim() ||
|
||||
[entry.name, ...(entry.aliases ?? [])].some((name) =>
|
||||
normal(name).includes(normal(typedValue))
|
||||
)
|
||||
)
|
||||
.slice(0, 10);
|
||||
const hasUncommittedInput = normal(typedValue) !== normal(label ?? "");
|
||||
const canCreateInput = Boolean(typedValue.trim()) && !exactMatch;
|
||||
const isSubrecipe = Boolean(item.subrecipe_id);
|
||||
|
||||
return (
|
||||
<tr
|
||||
class={`${
|
||||
dragging?.kind === "item" &&
|
||||
dragging.component === componentIndex &&
|
||||
dragging.index === itemIndex
|
||||
? "dragging"
|
||||
: ""
|
||||
}${isPending ? " pending-ingredient-row" : ""}${
|
||||
isSubrecipe ? " is-subrecipe-row" : ""
|
||||
}`}
|
||||
key={item.id}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<td class="quantity-column">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
aria-label="Quantity"
|
||||
value={item.quantity}
|
||||
onInput={(event) =>
|
||||
onUpdateQuantity(Number(event.currentTarget.value))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td class="unit-column">
|
||||
<select
|
||||
aria-label="Unit"
|
||||
value={item.unit_id}
|
||||
onChange={(event) => onUpdateUnit(event.currentTarget.value)}
|
||||
>
|
||||
{units.map((unit) => (
|
||||
<option value={unit.id} key={unit.id}>
|
||||
{unit.symbol || unit.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td class="entity-column">
|
||||
<div
|
||||
class={`row-entity-combobox${
|
||||
hasUncommittedInput ? " unmatched" : ""
|
||||
}${isSubrecipe ? " is-subrecipe" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={typedValue}
|
||||
placeholder="Ingredient or Recipe"
|
||||
aria-label={`Ingredient or recipe for row ${itemIndex + 1}`}
|
||||
aria-expanded={focusedItem === item.id}
|
||||
aria-autocomplete="list"
|
||||
onFocus={(event) => {
|
||||
onFocus();
|
||||
event.currentTarget.select();
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
onInput={(event) => onQueryChange(event.currentTarget.value)}
|
||||
/>
|
||||
{isPending && (
|
||||
<span
|
||||
class="ingredient-attention-icon"
|
||||
aria-label="New unmatched ingredient"
|
||||
title="New ingredient; details can be completed after saving"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="17"
|
||||
height="17"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{focusedItem === item.id && (
|
||||
<div class="row-entity-results" role="listbox">
|
||||
{shownChoices.map((entry) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${entry.kind}-${entry.id}`}
|
||||
role="option"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() =>
|
||||
onSelectChoice(entry.kind, entry.id, entry.name)
|
||||
}
|
||||
>
|
||||
<strong>{entry.name}</strong>
|
||||
<small class={`badge-${entry.kind}`}>
|
||||
{entry.kind === "recipe" ? "Recipe" : "Ingredient"}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
{canCreateInput && (
|
||||
<div class="row-create-actions">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onCreatePendingIngredient(typedValue)}
|
||||
>
|
||||
+ Create “{typedValue}”
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isPending && (
|
||||
<small class="pending-ingredient-help">
|
||||
New ingredient · saved automatically
|
||||
</small>
|
||||
)}
|
||||
</td>
|
||||
<td class="notes-column">
|
||||
<input
|
||||
class="line-notes"
|
||||
aria-label={`${label} notes`}
|
||||
value={item.notes ?? ""}
|
||||
placeholder="Add Notes"
|
||||
onInput={(event) => onUpdateNotes(event.currentTarget.value)}
|
||||
/>
|
||||
</td>
|
||||
{calculatePercent && percentMode === "bakers" && (
|
||||
<td class="base-column">
|
||||
<input
|
||||
aria-label={`${label} baker's base member`}
|
||||
type="checkbox"
|
||||
checked={item.basis_member}
|
||||
onChange={(event) => onUpdateBasis(event.currentTarget.checked)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{calculatePercent && (
|
||||
<td class="percent-column">
|
||||
{percentValue == null ? (
|
||||
<span title="A weight equivalency is required">—</span>
|
||||
) : (
|
||||
<input
|
||||
aria-label={`${label} percentage`}
|
||||
type="number"
|
||||
min="0"
|
||||
max={
|
||||
percentMode === "standard" || item.basis_member
|
||||
? 99.999
|
||||
: undefined
|
||||
}
|
||||
step="any"
|
||||
value={Math.round(percentValue * 1000) / 1000}
|
||||
onInput={(event) => onSetPercent(Number(event.currentTarget.value))}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td class="actions-column">
|
||||
<div class="row-actions-group">
|
||||
<button
|
||||
type="button"
|
||||
class="remove-row-btn"
|
||||
title="Delete"
|
||||
aria-label="Delete ingredient"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="drag-handle"
|
||||
title="Drag to reorder"
|
||||
aria-label="Drag to reorder"
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="22"
|
||||
height="22"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { createPortal } from "preact/compat";
|
||||
import type { RecipeStructure } from "../../lib/database";
|
||||
import type { DragState } from "./types";
|
||||
|
||||
type Props = {
|
||||
steps: RecipeStructure["steps"];
|
||||
methodTarget: HTMLElement | null;
|
||||
dragging: DragState;
|
||||
onStepsChange: (steps: RecipeStructure["steps"]) => void;
|
||||
onDragStart: (index: number) => void;
|
||||
onDrop: (fromIndex: number, toIndex: number) => void;
|
||||
onOpenBulkPrep: () => void;
|
||||
};
|
||||
|
||||
const uid = (prefix: string) => `${prefix}_${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
export default function RecipeMethodEditor({
|
||||
steps,
|
||||
methodTarget,
|
||||
dragging,
|
||||
onStepsChange,
|
||||
onDragStart,
|
||||
onDrop,
|
||||
onOpenBulkPrep,
|
||||
}: Props) {
|
||||
const content = (
|
||||
<section class="method-editor">
|
||||
<div class="method-header-row">
|
||||
<h2>
|
||||
Prep Method <small>{steps.length}</small>
|
||||
</h2>
|
||||
</div>
|
||||
<ol class="method-steps-list">
|
||||
{steps.map((step, index) => {
|
||||
const stepKind = step.instruction.trim().endsWith(":")
|
||||
? " prep-heading"
|
||||
: /^\(.+\)$/.test(step.instruction.trim())
|
||||
? " prep-note"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<li
|
||||
class={`method-step-card${
|
||||
dragging?.kind === "step" && dragging.index === index
|
||||
? " dragging"
|
||||
: ""
|
||||
}${stepKind}`}
|
||||
key={step.id}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={() => {
|
||||
if (dragging?.kind === "step") onDrop(dragging.index, index);
|
||||
}}
|
||||
>
|
||||
<span class="step-num">{index + 1}.</span>
|
||||
<div class="step-body">
|
||||
<textarea
|
||||
rows={2}
|
||||
class="step-textarea"
|
||||
placeholder="Add Prep Step"
|
||||
value={step.instruction}
|
||||
onInput={(event) =>
|
||||
onStepsChange(
|
||||
steps.map((value, position) =>
|
||||
position === index
|
||||
? { ...value, instruction: event.currentTarget.value }
|
||||
: value
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="step-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="remove-step-btn"
|
||||
title="Delete step"
|
||||
onClick={() =>
|
||||
onStepsChange(
|
||||
steps.filter((_, position) => position !== index)
|
||||
)
|
||||
}
|
||||
disabled={steps.length === 1}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="drag-handle"
|
||||
title="Drag to reorder"
|
||||
draggable
|
||||
onDragStart={() => onDragStart(index)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="22"
|
||||
height="22"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<div class="method-footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="action-link-btn"
|
||||
onClick={() =>
|
||||
onStepsChange([
|
||||
...steps,
|
||||
{ id: uid("step"), instruction: "New Section:", equipment_ids: [] },
|
||||
])
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z" />
|
||||
</svg>
|
||||
Add Header
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-link-btn"
|
||||
onClick={() =>
|
||||
onStepsChange([
|
||||
...steps,
|
||||
{ id: uid("step"), instruction: "(Note)", equipment_ids: [] },
|
||||
])
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z" />
|
||||
</svg>
|
||||
Add Note
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-prep-btn"
|
||||
onClick={onOpenBulkPrep}
|
||||
>
|
||||
Add Prep Steps
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
return methodTarget ? createPortal(content, methodTarget) : content;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./types";
|
||||
export * from "./useRecipeStructure";
|
||||
export { default as RecipeItemRow } from "./RecipeItemRow";
|
||||
export { default as RecipeMethodEditor } from "./RecipeMethodEditor";
|
||||
export { default as BulkIngredientImportModal } from "./BulkIngredientImportModal";
|
||||
export { default as BulkPrepStepsModal } from "./BulkPrepStepsModal";
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { RecipeStructure } from "../../lib/database";
|
||||
|
||||
export type Option = { id: string; name: string; aliases?: string[] };
|
||||
|
||||
export type RecipeEditorProps = {
|
||||
recipeId: string;
|
||||
initial: RecipeStructure;
|
||||
ingredients: Option[];
|
||||
recipes: Option[];
|
||||
units: Array<Option & { symbol: string }>;
|
||||
prepActions: Option[];
|
||||
autoYield?: boolean;
|
||||
showMethod?: boolean;
|
||||
weightRates?: Record<string, number | null>;
|
||||
};
|
||||
|
||||
export type DragState =
|
||||
| { kind: "component" | "item" | "step"; component?: number; index: number }
|
||||
| undefined;
|
||||
|
||||
export type EditorState = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
export type PercentMode = "standard" | "bakers";
|
||||
@@ -0,0 +1,676 @@
|
||||
import { useState, useEffect } from "preact/hooks";
|
||||
import type { RecipeStructure } from "../../lib/database";
|
||||
import { percentage, targetWeight } from "../../lib/percentages";
|
||||
import type {
|
||||
DragState,
|
||||
EditorState,
|
||||
Option,
|
||||
PercentMode,
|
||||
RecipeEditorProps,
|
||||
} from "./types";
|
||||
|
||||
export const uid = (prefix: string) =>
|
||||
`${prefix}_${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
export const normal = (value: string) =>
|
||||
value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
|
||||
const unitAliases: Record<string, string> = {
|
||||
g: "gram", gram: "gram", grams: "gram", kg: "kilogram", kilogram: "kilogram", kilograms: "kilogram",
|
||||
oz: "ounce_mass", ounce: "ounce_mass", ounces: "ounce_mass", lb: "pound", lbs: "pound", pound: "pound", pounds: "pound",
|
||||
tsp: "teaspoon_us", teaspoon: "teaspoon_us", teaspoons: "teaspoon_us", tbsp: "tablespoon_us", tablespoon: "tablespoon_us", tablespoons: "tablespoon_us",
|
||||
c: "cup_us", cup: "cup_us", cups: "cup_us", ml: "milliliter", milliliter: "milliliter", milliliters: "milliliter",
|
||||
l: "liter", liter: "liter", liters: "liter", ea: "each", each: "each", clove: "each", cloves: "each",
|
||||
};
|
||||
|
||||
export function useRecipeStructure({
|
||||
recipeId,
|
||||
initial,
|
||||
ingredients,
|
||||
recipes,
|
||||
units,
|
||||
autoYield = false,
|
||||
weightRates = {},
|
||||
}: RecipeEditorProps) {
|
||||
const [data, setData] = useState(initial);
|
||||
const [query, setQuery] = useState<Record<string, string>>({});
|
||||
const [rowQuery, setRowQuery] = useState<Record<string, string>>({});
|
||||
const [focusedItem, setFocusedItem] = useState<string>();
|
||||
const [draftNotes, setDraftNotes] = useState<Record<string, string>>({});
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [bulkText, setBulkText] = useState("");
|
||||
const [bulkError, setBulkError] = useState("");
|
||||
const [bulkParsing, setBulkParsing] = useState(false);
|
||||
const [prepOpen, setPrepOpen] = useState(false);
|
||||
const [prepText, setPrepText] = useState("");
|
||||
const [prepError, setPrepError] = useState("");
|
||||
const [pendingIngredients, setPendingIngredients] = useState<Option[]>([]);
|
||||
const [methodTarget, setMethodTarget] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMethodTarget(document.getElementById("recipe-method-editor-slot"));
|
||||
}, []);
|
||||
|
||||
const [baseline, setBaseline] = useState(JSON.stringify(initial));
|
||||
const [state, setState] = useState<EditorState>("idle");
|
||||
const [message, setMessage] = useState("");
|
||||
const [dragging, setDragging] = useState<DragState>();
|
||||
const [calculatePercent, setCalculatePercent] = useState(true);
|
||||
const [percentMode, setPercentMode] = useState<PercentMode>("standard");
|
||||
|
||||
const updateComponent = (
|
||||
index: number,
|
||||
update: (
|
||||
component: RecipeStructure["components"][number]
|
||||
) => RecipeStructure["components"][number]
|
||||
) =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
components: current.components.map((component, position) =>
|
||||
position === index ? update(component) : component
|
||||
),
|
||||
}));
|
||||
|
||||
const allItems = () =>
|
||||
data.components.flatMap((component) => component.items);
|
||||
|
||||
const rate = (
|
||||
item: RecipeStructure["components"][number]["items"][number]
|
||||
) =>
|
||||
item.unit_id === "gram"
|
||||
? 1
|
||||
: weightRates[
|
||||
`${
|
||||
item.ingredient_id
|
||||
? `ingredient:${item.ingredient_id}`
|
||||
: `recipe:${item.subrecipe_id}`
|
||||
}:${item.unit_id}`
|
||||
];
|
||||
|
||||
const weight = (
|
||||
item: RecipeStructure["components"][number]["items"][number]
|
||||
) => {
|
||||
const value = rate(item);
|
||||
return value == null ? undefined : item.quantity * value;
|
||||
};
|
||||
|
||||
const calculatedYieldWeight = () =>
|
||||
allItems().reduce((sum, item) => sum + (weight(item) ?? 0), 0);
|
||||
|
||||
const unconvertedYieldItems = () =>
|
||||
allItems().filter((item) => weight(item) == null).length;
|
||||
|
||||
const denominator = () =>
|
||||
percentMode === "standard"
|
||||
? allItems().reduce((sum, item) => sum + (weight(item) ?? 0), 0)
|
||||
: allItems()
|
||||
.filter((item) => item.basis_member)
|
||||
.reduce((sum, item) => sum + (weight(item) ?? 0), 0);
|
||||
|
||||
const percent = (
|
||||
item: RecipeStructure["components"][number]["items"][number]
|
||||
) => percentage(weight(item), denominator());
|
||||
|
||||
const setPercent = (
|
||||
componentIndex: number,
|
||||
itemIndex: number,
|
||||
target: number
|
||||
) => {
|
||||
const item = data.components[componentIndex].items[itemIndex],
|
||||
itemRate = rate(item),
|
||||
grams = targetWeight(
|
||||
target,
|
||||
weight(item) ?? 0,
|
||||
denominator(),
|
||||
percentMode === "standard" || item.basis_member
|
||||
);
|
||||
if (itemRate == null || grams == null) return;
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex ? { ...line, quantity: grams / itemRate } : line
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const move = <T,>(items: T[], from: number, to: number) => {
|
||||
const result = [...items];
|
||||
const [entry] = result.splice(from, 1);
|
||||
result.splice(to, 0, entry);
|
||||
return result;
|
||||
};
|
||||
|
||||
const addLine = (componentIndex: number, selected: string) => {
|
||||
const componentId = data.components[componentIndex].id;
|
||||
const [kind, id] = selected.split(":", 2);
|
||||
updateComponent(componentIndex, (component) => ({
|
||||
...component,
|
||||
items: [
|
||||
...component.items,
|
||||
{
|
||||
id: uid("line"),
|
||||
...(kind === "ingredient"
|
||||
? { ingredient_id: id }
|
||||
: { subrecipe_id: id }),
|
||||
quantity: 1,
|
||||
unit_id: "gram",
|
||||
basis_member: false,
|
||||
optional: false,
|
||||
nutrition_retention_factor: 1,
|
||||
prep: [],
|
||||
...(draftNotes[componentId]?.trim()
|
||||
? { notes: draftNotes[componentId].trim() }
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
}));
|
||||
setQuery((current) => ({
|
||||
...current,
|
||||
[data.components[componentIndex].id]: "",
|
||||
}));
|
||||
setDraftNotes((current) => ({ ...current, [componentId]: "" }));
|
||||
};
|
||||
|
||||
const replaceLineReference = (
|
||||
componentIndex: number,
|
||||
itemIndex: number,
|
||||
kind: "ingredient" | "recipe",
|
||||
id: string,
|
||||
name: string
|
||||
) => {
|
||||
const itemId = data.components[componentIndex].items[itemIndex].id;
|
||||
updateComponent(componentIndex, (component) => ({
|
||||
...component,
|
||||
items: component.items.map((line, index) =>
|
||||
index === itemIndex
|
||||
? {
|
||||
...line,
|
||||
ingredient_id: kind === "ingredient" ? id : undefined,
|
||||
subrecipe_id: kind === "recipe" ? id : undefined,
|
||||
}
|
||||
: line
|
||||
),
|
||||
}));
|
||||
setRowQuery((current) => ({ ...current, [itemId]: name }));
|
||||
setFocusedItem(undefined);
|
||||
};
|
||||
|
||||
const createPendingRowIngredient = (
|
||||
componentIndex: number,
|
||||
itemIndex: number,
|
||||
name: string
|
||||
) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const existing = [...ingredients, ...pendingIngredients].find(
|
||||
(ingredient) => normal(ingredient.name) === normal(trimmed)
|
||||
);
|
||||
if (existing) {
|
||||
replaceLineReference(
|
||||
componentIndex,
|
||||
itemIndex,
|
||||
"ingredient",
|
||||
existing.id,
|
||||
existing.name
|
||||
);
|
||||
return;
|
||||
}
|
||||
const takenIds = new Set(
|
||||
[...ingredients, ...pendingIngredients].map((ingredient) => ingredient.id)
|
||||
);
|
||||
const base = normal(trimmed).replace(/ /g, "_") || "new_ingredient";
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (takenIds.has(id)) id = `${base}_${suffix++}`;
|
||||
setPendingIngredients((current) => [...current, { id, name: trimmed }]);
|
||||
replaceLineReference(componentIndex, itemIndex, "ingredient", id, trimmed);
|
||||
};
|
||||
|
||||
const createPendingIngredient = (componentIndex: number, name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const existing = [...ingredients, ...pendingIngredients].find(
|
||||
(ingredient) => normal(ingredient.name) === normal(trimmed)
|
||||
);
|
||||
if (existing) {
|
||||
addLine(componentIndex, `ingredient:${existing.id}`);
|
||||
return;
|
||||
}
|
||||
const takenIds = new Set(
|
||||
[...ingredients, ...pendingIngredients].map((ingredient) => ingredient.id)
|
||||
);
|
||||
const base = normal(trimmed).replace(/ /g, "_") || "new_ingredient";
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (takenIds.has(id)) id = `${base}_${suffix++}`;
|
||||
setPendingIngredients((current) => [...current, { id, name: trimmed }]);
|
||||
addLine(componentIndex, `ingredient:${id}`);
|
||||
};
|
||||
|
||||
const identityTokens = (value: string) =>
|
||||
normal(value)
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((token) =>
|
||||
token.length > 3 && token.endsWith("s") ? token.slice(0, -1) : token
|
||||
);
|
||||
|
||||
const addBulkIngredients = async () => {
|
||||
if (!bulkText.trim()) {
|
||||
setBulkError("Enter at least one ingredient.");
|
||||
return;
|
||||
}
|
||||
setBulkParsing(true);
|
||||
setBulkError("");
|
||||
let parsed: {
|
||||
components: Array<{
|
||||
name: string;
|
||||
items: Array<{
|
||||
source_line: string;
|
||||
quantity: number | null;
|
||||
unit: string | null;
|
||||
ingredient: string;
|
||||
preparation: string | null;
|
||||
note: string | null;
|
||||
optional: boolean;
|
||||
alternatives: string[];
|
||||
}>;
|
||||
}>;
|
||||
warnings: string[];
|
||||
};
|
||||
try {
|
||||
const response = await fetch("/api/app/recipes/parse-ingredients", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ text: bulkText }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(result.error ?? "Unable to parse ingredients.");
|
||||
parsed = result;
|
||||
} catch (error) {
|
||||
setBulkError(
|
||||
error instanceof Error ? error.message : "Unable to parse ingredients."
|
||||
);
|
||||
setBulkParsing(false);
|
||||
return;
|
||||
}
|
||||
const available = [
|
||||
...[...ingredients, ...pendingIngredients].map((item) => ({
|
||||
...item,
|
||||
kind: "ingredient" as const,
|
||||
})),
|
||||
...recipes
|
||||
.filter((item) => item.id !== recipeId)
|
||||
.map((item) => ({ ...item, kind: "recipe" as const })),
|
||||
];
|
||||
const components: RecipeStructure["components"] = [];
|
||||
const unmatched: string[] = [];
|
||||
const created: Option[] = [];
|
||||
const takenIds = new Set(
|
||||
[...ingredients, ...pendingIngredients].map((item) => item.id)
|
||||
);
|
||||
const newIngredient = (name: string) => {
|
||||
const base = normal(name).replace(/ /g, "_") || "imported_ingredient";
|
||||
let id = base,
|
||||
suffix = 2;
|
||||
while (takenIds.has(id)) id = `${base}_${suffix++}`;
|
||||
takenIds.add(id);
|
||||
const ingredient = { id, name: name.trim() };
|
||||
created.push(ingredient);
|
||||
const option = { ...ingredient, kind: "ingredient" as const };
|
||||
available.push(option);
|
||||
return option;
|
||||
};
|
||||
for (const parsedComponent of parsed.components) {
|
||||
const component = {
|
||||
id: uid("component"),
|
||||
name: parsedComponent.name || "Main",
|
||||
items: [],
|
||||
notes: [],
|
||||
} as RecipeStructure["components"][number];
|
||||
components.push(component);
|
||||
for (const parsedItem of parsedComponent.items) {
|
||||
const wanted = normal(parsedItem.ingredient);
|
||||
const tokens = identityTokens(wanted);
|
||||
const reducedTokens = tokens.filter(
|
||||
(token) =>
|
||||
![
|
||||
"fresh",
|
||||
"dried",
|
||||
"flake",
|
||||
"leave",
|
||||
"chopped",
|
||||
"minced",
|
||||
"sliced",
|
||||
"crushed",
|
||||
"granulated",
|
||||
].includes(token)
|
||||
);
|
||||
const labels = (item: (typeof available)[number]) =>
|
||||
[item.name, ...(item.aliases ?? [])].map(normal);
|
||||
const labelTokens = (item: (typeof available)[number]) =>
|
||||
labels(item).map(identityTokens);
|
||||
const match =
|
||||
available.find((item) => labels(item).includes(wanted)) ??
|
||||
available.find((item) =>
|
||||
labels(item).some(
|
||||
(label) => label.includes(wanted) || wanted.includes(label)
|
||||
)
|
||||
) ??
|
||||
available.find((item) =>
|
||||
labelTokens(item).some((label) =>
|
||||
tokens.every((token) => label.includes(token))
|
||||
)
|
||||
) ??
|
||||
available.find(
|
||||
(item) =>
|
||||
reducedTokens.length &&
|
||||
labelTokens(item).some((label) =>
|
||||
reducedTokens.every((token) => label.includes(token))
|
||||
)
|
||||
) ??
|
||||
newIngredient(parsedItem.ingredient);
|
||||
const unitId = parsedItem.unit
|
||||
? unitAliases[normal(parsedItem.unit)]
|
||||
: "each";
|
||||
if (!match || !unitId || !units.some((unit) => unit.id === unitId)) {
|
||||
unmatched.push(parsedItem.source_line);
|
||||
continue;
|
||||
}
|
||||
const notes = [
|
||||
parsedItem.preparation,
|
||||
parsedItem.note,
|
||||
parsedItem.alternatives.length
|
||||
? `Alternatives: ${parsedItem.alternatives.join("; ")}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
component.items.push({
|
||||
id: uid("line"),
|
||||
...(match.kind === "ingredient"
|
||||
? { ingredient_id: match.id }
|
||||
: { subrecipe_id: match.id }),
|
||||
quantity: parsedItem.quantity ?? 1,
|
||||
unit_id: unitId,
|
||||
basis_member: false,
|
||||
optional: parsedItem.optional,
|
||||
nutrition_retention_factor: 1,
|
||||
prep: [],
|
||||
...(notes ? { notes } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
const populated = components.filter((entry) => entry.items.length > 0);
|
||||
if (unmatched.length || !populated.length) {
|
||||
setBulkError(
|
||||
unmatched.length
|
||||
? `Could not match: ${unmatched.join("; ")}`
|
||||
: "No ingredients could be matched."
|
||||
);
|
||||
setBulkParsing(false);
|
||||
return;
|
||||
}
|
||||
setPendingIngredients((current) => [...current, ...created]);
|
||||
setData((current) => {
|
||||
const additions = [...populated];
|
||||
const currentComponents = current.components.map((component) => ({
|
||||
...component,
|
||||
items: [...component.items],
|
||||
}));
|
||||
if (
|
||||
additions[0]?.name.toLowerCase() === "main" &&
|
||||
currentComponents.length
|
||||
)
|
||||
currentComponents.at(-1)!.items.push(...additions.shift()!.items);
|
||||
return { ...current, components: [...currentComponents, ...additions] };
|
||||
});
|
||||
const creationMessage = created.length
|
||||
? `${created.length} new canonical ingredient${
|
||||
created.length === 1 ? " is" : "s are"
|
||||
} pending (${created.map((item) => item.name).join(", ")}) and will be created when you save. `
|
||||
: "";
|
||||
setMessage(
|
||||
`${creationMessage}${
|
||||
parsed.warnings.length
|
||||
? `Parser warnings: ${parsed.warnings.join(" ")}`
|
||||
: "Review imported ingredients before saving."
|
||||
}`
|
||||
);
|
||||
setBulkText("");
|
||||
setBulkError("");
|
||||
setBulkOpen(false);
|
||||
setBulkParsing(false);
|
||||
};
|
||||
|
||||
const addBulkPrepSteps = () => {
|
||||
const lines = prepText
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (!lines.length) {
|
||||
setPrepError("Field is required");
|
||||
return;
|
||||
}
|
||||
const additions = lines.map((instruction) => ({
|
||||
id: uid("step"),
|
||||
instruction,
|
||||
equipment_ids: [],
|
||||
}));
|
||||
setData((current) => ({
|
||||
...current,
|
||||
steps:
|
||||
current.steps.length === 1 && !current.steps[0].instruction.trim()
|
||||
? additions
|
||||
: [...current.steps, ...additions],
|
||||
}));
|
||||
setPrepText("");
|
||||
setPrepError("");
|
||||
setPrepOpen(false);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const unresolvedRow = data.components
|
||||
.flatMap((component) => component.items)
|
||||
.find((item) => {
|
||||
const typed = rowQuery[item.id];
|
||||
if (typed == null) return false;
|
||||
const currentLabel = item.ingredient_id
|
||||
? [...ingredients, ...pendingIngredients].find(
|
||||
(entry) => entry.id === item.ingredient_id
|
||||
)?.name
|
||||
: recipes.find((entry) => entry.id === item.subrecipe_id)?.name;
|
||||
return normal(typed) !== normal(currentLabel ?? "");
|
||||
});
|
||||
if (unresolvedRow) {
|
||||
setState("error");
|
||||
setMessage(
|
||||
"Choose a search result or create the unmatched ingredient before saving."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
setState("saving");
|
||||
setMessage("");
|
||||
const detailsForm =
|
||||
document.querySelector<HTMLFormElement>("#recipe-details-form");
|
||||
const details = detailsForm ? new FormData(detailsForm) : undefined;
|
||||
const additionalForm = document.querySelector<HTMLFormElement>(
|
||||
"#recipe-additional-form"
|
||||
);
|
||||
const additional = additionalForm ? new FormData(additionalForm) : undefined;
|
||||
const servingsText = String(details?.get("yield_servings") ?? "").trim();
|
||||
const shelfQuantityText = String(
|
||||
additional?.get("shelf_quantity") ?? ""
|
||||
).trim();
|
||||
const shelfUnit = String(additional?.get("shelf_unit") ?? "").trim();
|
||||
const payload = {
|
||||
...data,
|
||||
new_ingredients: pendingIngredients.map(({ id, name }) => ({ id, name })),
|
||||
...(details
|
||||
? {
|
||||
metadata: {
|
||||
title: String(details.get("title") ?? "").trim(),
|
||||
yield_quantity: Number(details.get("yield_quantity")),
|
||||
yield_unit_id: String(details.get("yield_unit_id") ?? ""),
|
||||
yield_servings: servingsText ? Number(servingsText) : null,
|
||||
yield_basis:
|
||||
String(details.get("yield_basis") ?? "").trim() || null,
|
||||
station: String(additional?.get("station") ?? "").trim() || null,
|
||||
cover_media_url:
|
||||
String(additional?.get("cover_media_url") ?? "").trim() || null,
|
||||
tags: String(additional?.get("tags") ?? "")
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
shelf_life:
|
||||
shelfQuantityText && shelfUnit
|
||||
? {
|
||||
quantity: Number(shelfQuantityText),
|
||||
unit: shelfUnit,
|
||||
storage_condition:
|
||||
String(
|
||||
additional?.get("storage_condition") ?? ""
|
||||
).trim() || undefined,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
components: data.components.map((component) => ({
|
||||
...component,
|
||||
items: component.items.map((item) => ({
|
||||
...item,
|
||||
...(calculatePercent ? { percentage: percent(item) } : {}),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
const response = await fetch(`/api/app/recipes/${recipeId}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
setState("error");
|
||||
setMessage(result.error ?? "Unable to save.");
|
||||
return false;
|
||||
}
|
||||
setData(result);
|
||||
setPendingIngredients([]);
|
||||
setBaseline(JSON.stringify(result));
|
||||
const saveVersionInput = document.querySelector<HTMLInputElement>(
|
||||
'#recipe-details-form input[name="save_version"]'
|
||||
);
|
||||
if (saveVersionInput) saveVersionInput.value = String(result.save_version);
|
||||
setState("saved");
|
||||
setMessage("Changes saved.");
|
||||
document.dispatchEvent(new CustomEvent("recipe:dirty", { detail: false }));
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleDone = (event: Event) => {
|
||||
const detail = (
|
||||
event as CustomEvent<{ complete: (saved: boolean) => void }>
|
||||
).detail;
|
||||
void save().then(detail.complete);
|
||||
};
|
||||
document.addEventListener("recipe:save-structure", handleDone);
|
||||
return () =>
|
||||
document.removeEventListener("recipe:save-structure", handleDone);
|
||||
}, [data, pendingIngredients, calculatePercent, percentMode]);
|
||||
|
||||
useEffect(() => {
|
||||
document.dispatchEvent(new CustomEvent("recipe:editor-ready"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bulkOpen && !prepOpen) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setBulkOpen(false);
|
||||
setPrepOpen(false);
|
||||
}
|
||||
};
|
||||
document.body.style.overflow = "hidden";
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [bulkOpen, prepOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("recipe:dirty", {
|
||||
detail:
|
||||
JSON.stringify(data) !== baseline || pendingIngredients.length > 0,
|
||||
})
|
||||
);
|
||||
}, [data, baseline, pendingIngredients]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoYield) return;
|
||||
const quantity = calculatedYieldWeight();
|
||||
const quantityInput = document.querySelector<HTMLInputElement>(
|
||||
'#recipe-details-form input[name="yield_quantity"]'
|
||||
);
|
||||
const unitInput = document.querySelector<HTMLSelectElement>(
|
||||
'#recipe-details-form select[name="yield_unit_id"]'
|
||||
);
|
||||
if (quantityInput && quantity > 0)
|
||||
quantityInput.value = String(Math.round(quantity * 1000) / 1000);
|
||||
if (unitInput) unitInput.value = "gram";
|
||||
}, [data, autoYield]);
|
||||
|
||||
return {
|
||||
data,
|
||||
setData,
|
||||
query,
|
||||
setQuery,
|
||||
rowQuery,
|
||||
setRowQuery,
|
||||
focusedItem,
|
||||
setFocusedItem,
|
||||
draftNotes,
|
||||
setDraftNotes,
|
||||
bulkOpen,
|
||||
setBulkOpen,
|
||||
bulkText,
|
||||
setBulkText,
|
||||
bulkError,
|
||||
setBulkError,
|
||||
bulkParsing,
|
||||
prepOpen,
|
||||
setPrepOpen,
|
||||
prepText,
|
||||
setPrepText,
|
||||
prepError,
|
||||
setPrepError,
|
||||
pendingIngredients,
|
||||
methodTarget,
|
||||
baseline,
|
||||
state,
|
||||
message,
|
||||
dragging,
|
||||
setDragging,
|
||||
calculatePercent,
|
||||
setCalculatePercent,
|
||||
percentMode,
|
||||
setPercentMode,
|
||||
updateComponent,
|
||||
percent,
|
||||
setPercent,
|
||||
move,
|
||||
addLine,
|
||||
replaceLineReference,
|
||||
createPendingRowIngredient,
|
||||
createPendingIngredient,
|
||||
addBulkIngredients,
|
||||
addBulkPrepSteps,
|
||||
save,
|
||||
unconvertedYieldItems,
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,13 @@ describe("recipe costing", () => {
|
||||
expect(result.perServing).toBeCloseTo(0.125);
|
||||
expect(result.per100g).toBeCloseTo(0.25);
|
||||
expect(result.completeness).toBe(1);
|
||||
expect(result.lines).toMatchObject([{
|
||||
subjectId: "flour",
|
||||
kind: "ingredient",
|
||||
cost: 0.5,
|
||||
completeness: 1,
|
||||
purchase: { id: "flour_bag", price: 5, currency: "USD" },
|
||||
}]);
|
||||
});
|
||||
|
||||
it("inflates purchased cost for prep loss", () => {
|
||||
@@ -58,5 +65,11 @@ describe("recipe costing", () => {
|
||||
const result = calculateCost(plate, catalogs([base, plate]));
|
||||
expect(result.batch).toBeCloseTo(0.125);
|
||||
expect(result.completeness).toBe(1);
|
||||
expect(result.lines[0]).toMatchObject({
|
||||
subjectId: "dough",
|
||||
kind: "recipe",
|
||||
cost: 0.125,
|
||||
children: [{ subjectId: "flour", kind: "ingredient" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+45
-8
@@ -10,6 +10,29 @@ export type CostResult = {
|
||||
pricedWeightG: number;
|
||||
completeness: number;
|
||||
warnings: string[];
|
||||
lines: CostLine[];
|
||||
};
|
||||
|
||||
export type CostLine = {
|
||||
id: string;
|
||||
subjectId: string;
|
||||
name: string;
|
||||
kind: "ingredient" | "recipe";
|
||||
cost?: number;
|
||||
weightG?: number;
|
||||
completeness: number;
|
||||
purchase?: {
|
||||
id: string;
|
||||
name: string;
|
||||
packageQuantity: number;
|
||||
packageUnitId: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
effectiveAt: string;
|
||||
supplier?: string;
|
||||
sku?: string;
|
||||
};
|
||||
children?: CostLine[];
|
||||
};
|
||||
|
||||
type Catalogs = {
|
||||
@@ -46,12 +69,12 @@ function usableCostPerGram(ingredient: Ingredient, item: PurchaseItem, currency:
|
||||
}
|
||||
}
|
||||
|
||||
function ingredientRate(ingredient: Ingredient, catalogs: Catalogs, currency: string): number | undefined {
|
||||
function ingredientCostSource(ingredient: Ingredient, catalogs: Catalogs, currency: string) {
|
||||
return [...catalogs.purchaseItems.values()]
|
||||
.filter((item) => item.ingredient_id === ingredient.id && item.status === "active")
|
||||
.map((item) => usableCostPerGram(ingredient, item, currency, catalogs.units))
|
||||
.filter((rate): rate is number => rate != null)
|
||||
.sort((a, b) => a - b)[0];
|
||||
.map((item) => ({ item, rate: usableCostPerGram(ingredient, item, currency, catalogs.units), price: latestPrice(item, currency) }))
|
||||
.filter((entry): entry is typeof entry & { rate: number; price: NonNullable<typeof entry.price> } => entry.rate != null && entry.price != null)
|
||||
.sort((a, b) => a.rate - b.rate)[0];
|
||||
}
|
||||
|
||||
export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "USD", stack: string[] = []): CostResult {
|
||||
@@ -60,6 +83,7 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
|
||||
let inputWeightG = 0;
|
||||
let pricedWeightG = 0;
|
||||
const warnings: string[] = [];
|
||||
const lines: CostLine[] = [];
|
||||
|
||||
for (const item of recipe.components.flatMap((component) => component.items)) {
|
||||
if (item.optional) continue;
|
||||
@@ -71,16 +95,23 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
|
||||
usableWeightG = grams(item.amount.quantity, item.amount.unit_id, ingredient, catalogs.units);
|
||||
} catch (error) {
|
||||
warnings.push(`${ingredient.name}: ${(error as Error).message}`);
|
||||
lines.push({ id:item.id, subjectId:ingredient.id, name:ingredient.name, kind:"ingredient", completeness:0 });
|
||||
continue;
|
||||
}
|
||||
inputWeightG += usableWeightG;
|
||||
const rate = ingredientRate(ingredient, catalogs, currency);
|
||||
if (rate == null) {
|
||||
const source = ingredientCostSource(ingredient, catalogs, currency);
|
||||
if (!source) {
|
||||
warnings.push(`${ingredient.name}: no active ${currency} purchase price with a convertible package size`);
|
||||
lines.push({ id:item.id, subjectId:ingredient.id, name:ingredient.name, kind:"ingredient", weightG:usableWeightG, completeness:0 });
|
||||
continue;
|
||||
}
|
||||
batch += (usableWeightG / prepYieldFactor(item, catalogs)) * rate;
|
||||
const lineCost=(usableWeightG / prepYieldFactor(item, catalogs)) * source.rate;
|
||||
batch += lineCost;
|
||||
pricedWeightG += usableWeightG;
|
||||
lines.push({
|
||||
id:item.id, subjectId:ingredient.id, name:ingredient.name, kind:"ingredient", cost:lineCost, weightG:usableWeightG, completeness:1,
|
||||
purchase:{ id:source.item.id, name:source.item.name, packageQuantity:source.item.package.quantity, packageUnitId:source.item.package.unit_id, price:source.price.amount, currency:source.price.currency, effectiveAt:source.price.effective_at, supplier:source.item.supplier_id, sku:source.item.supplier_sku },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -88,6 +119,7 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
|
||||
if (!child) throw new Error(`Unknown sub-recipe: ${item.reference.recipe_id}`);
|
||||
if (item.reference.component_id) {
|
||||
warnings.push(`${child.title}: component-specific costing is not available for ${item.reference.component_id}`);
|
||||
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", completeness:0 });
|
||||
continue;
|
||||
}
|
||||
const childResult = calculateCost(child, catalogs, currency, [...stack, recipe.id]);
|
||||
@@ -96,6 +128,7 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
|
||||
usedWeightG = convertWithIngredientMeasures(item.amount, "gram", { id: child.id, name: child.title, schema_version: 2, status: "active", categories: [],measure_conversions:child.measure_conversions }, catalogs.units).quantity;
|
||||
} catch (error) {
|
||||
warnings.push(`${child.title}: ${(error as Error).message}`);
|
||||
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", completeness:0, children:childResult.lines });
|
||||
continue;
|
||||
}
|
||||
inputWeightG += usedWeightG;
|
||||
@@ -104,11 +137,14 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
|
||||
: undefined;
|
||||
if (!childYieldG || childResult.batch == null) {
|
||||
warnings.push(`${child.title}: sub-recipe cost requires a positive mass yield and at least one priced input`);
|
||||
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", weightG:usedWeightG, completeness:0, children:childResult.lines });
|
||||
continue;
|
||||
}
|
||||
const factor = usedWeightG / childYieldG;
|
||||
batch += childResult.batch * factor / prepYieldFactor(item, catalogs);
|
||||
const lineCost=childResult.batch * factor / prepYieldFactor(item, catalogs);
|
||||
batch += lineCost;
|
||||
pricedWeightG += usedWeightG * childResult.completeness;
|
||||
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", cost:lineCost, weightG:usedWeightG, completeness:childResult.completeness, children:childResult.lines });
|
||||
warnings.push(...childResult.warnings.map((warning) => `${child.title}: ${warning}`));
|
||||
}
|
||||
|
||||
@@ -128,5 +164,6 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
|
||||
pricedWeightG,
|
||||
completeness: inputWeightG > 0 ? pricedWeightG / inputWeightG : 0,
|
||||
warnings: [...new Set(warnings)],
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
+37
-16
@@ -1,19 +1,40 @@
|
||||
import type { Equipment, Ingredient, PrepAction, PurchaseItem, Recipe, Unit, SourceMapping } from "./types";
|
||||
import type {
|
||||
Equipment,
|
||||
Ingredient,
|
||||
PrepAction,
|
||||
PurchaseItem,
|
||||
Recipe,
|
||||
Unit,
|
||||
SourceMapping,
|
||||
} from "./types";
|
||||
import { databaseProjection, openDatabase } from "./database";
|
||||
|
||||
const database = openDatabase();
|
||||
if (!database) throw new Error("Database unavailable. Run npm run db:reset first.");
|
||||
const projection = databaseProjection(database) as {
|
||||
ingredients: Ingredient[]; recipes: Recipe[]; units: Unit[]; equipment: Equipment[];
|
||||
prepActions: PrepAction[]; purchaseItems: PurchaseItem[]; sourceMappings: SourceMapping[];
|
||||
};
|
||||
database.close();
|
||||
export function loadCatalogs() {
|
||||
const database = openDatabase();
|
||||
if (!database) throw new Error("Database unavailable. Run npm run db:reset first.");
|
||||
try {
|
||||
const projection = databaseProjection(database) as {
|
||||
ingredients: Ingredient[];
|
||||
recipes: Recipe[];
|
||||
units: Unit[];
|
||||
equipment: Equipment[];
|
||||
prepActions: PrepAction[];
|
||||
purchaseItems: PurchaseItem[];
|
||||
sourceMappings: SourceMapping[];
|
||||
};
|
||||
const map = <T extends { id: string }>(values: T[]) =>
|
||||
new Map(values.map((value) => [value.id, value]));
|
||||
|
||||
const map = <T extends { id: string }>(values: T[]) => new Map(values.map((value) => [value.id, value]));
|
||||
export const ingredients = map(projection.ingredients);
|
||||
export const recipes = map(projection.recipes);
|
||||
export const units = map(projection.units);
|
||||
export const equipment = map(projection.equipment);
|
||||
export const prepActions = map(projection.prepActions);
|
||||
export const purchaseItems = map(projection.purchaseItems);
|
||||
export const sourceMappings = map(projection.sourceMappings);
|
||||
return {
|
||||
ingredients: map(projection.ingredients),
|
||||
recipes: map(projection.recipes),
|
||||
units: map(projection.units),
|
||||
equipment: map(projection.equipment),
|
||||
prepActions: map(projection.prepActions),
|
||||
purchaseItems: map(projection.purchaseItems),
|
||||
sourceMappings: map(projection.sourceMappings),
|
||||
};
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import {
|
||||
duplicateRecipe,
|
||||
editableRecipe,
|
||||
permanentlyDeleteArchivedItems,
|
||||
recipeQualityRows,
|
||||
recipeStructure,
|
||||
restoreArchivedItems,
|
||||
saveRecipeMetadata,
|
||||
saveRecipeStructure,
|
||||
} from "./database";
|
||||
|
||||
function createTestDatabase(): DatabaseSync {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
const schemaPath = path.resolve(process.cwd(), "migrations", "001_initial.sql");
|
||||
const schemaSql = fs.readFileSync(schemaPath, "utf8");
|
||||
db.exec(schemaSql);
|
||||
|
||||
// Seed baseline units
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"gram", "Gram", "g", "mass", "metric", null, null, null
|
||||
);
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"kilogram", "Kilogram", "kg", "mass", "metric", "gram", 1000, null
|
||||
);
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"each", "Each", "ea", "count", "customary", null, null, null
|
||||
);
|
||||
|
||||
// Seed baseline ingredient
|
||||
db.prepare(
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("flour", 2, "All-Purpose Flour", "active", "[]", "[]", "{}");
|
||||
|
||||
// Seed baseline recipe
|
||||
db.prepare(
|
||||
"INSERT INTO recipes(id, schema_version, save_version, title, summary, categories_json, tags_json, yield_quantity, yield_unit_id, yield_servings, yield_basis, notes_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(
|
||||
"bread",
|
||||
2,
|
||||
1,
|
||||
"Country Bread",
|
||||
"Crusty artisan loaf",
|
||||
"[]",
|
||||
"[]",
|
||||
500,
|
||||
"gram",
|
||||
1,
|
||||
"measured",
|
||||
"[]",
|
||||
"{}"
|
||||
);
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO recipe_components(recipe_id, id, position, name, notes_json) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("bread", "comp_1", 1, "Dough", "[]");
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO recipe_items(recipe_id, component_id, id, position, ingredient_id, quantity, unit_id, basis_member, optional) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("bread", "comp_1", "line_1", 1, "flour", 500, "gram", 1, 0);
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO recipe_steps(recipe_id, id, position, instruction, critical_control_point) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("bread", "step_1", 1, "Mix flour and water.", 0);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("database optimistic concurrency", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("updates recipe metadata when save_version matches", () => {
|
||||
const updated = saveRecipeMetadata(db, "bread", 1, {
|
||||
title: "Country Sourdough",
|
||||
summary: "Long-fermented loaf",
|
||||
categories_json: "[]",
|
||||
tags_json: "[]",
|
||||
yield_quantity: 600,
|
||||
yield_unit_id: "gram",
|
||||
yield_servings: 2,
|
||||
yield_basis: "measured",
|
||||
});
|
||||
|
||||
expect(updated.title).toBe("Country Sourdough");
|
||||
expect(updated.save_version).toBe(2);
|
||||
expect(updated.yield_quantity).toBe(600);
|
||||
});
|
||||
|
||||
it("rejects metadata updates when save_version is stale", () => {
|
||||
expect(() =>
|
||||
saveRecipeMetadata(db, "bread", 999, {
|
||||
title: "Conflict Sourdough",
|
||||
summary: null,
|
||||
categories_json: "[]",
|
||||
tags_json: "[]",
|
||||
yield_quantity: 500,
|
||||
yield_unit_id: "gram",
|
||||
yield_servings: 1,
|
||||
yield_basis: null,
|
||||
})
|
||||
).toThrow(/changed in another tab/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recipe structure saving & transactions", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("saves components, lines, steps, and new ingredients atomically", () => {
|
||||
const structure = saveRecipeStructure(db, "bread", {
|
||||
save_version: 1,
|
||||
new_ingredients: [{ id: "water", name: "Filtered Water" }],
|
||||
components: [
|
||||
{
|
||||
id: "comp_1",
|
||||
name: "Main Dough",
|
||||
items: [
|
||||
{
|
||||
id: "line_1",
|
||||
ingredient_id: "flour",
|
||||
quantity: 400,
|
||||
unit_id: "gram",
|
||||
basis_member: true,
|
||||
optional: false,
|
||||
prep: [],
|
||||
},
|
||||
{
|
||||
id: "line_2",
|
||||
ingredient_id: "water",
|
||||
quantity: 300,
|
||||
unit_id: "gram",
|
||||
basis_member: false,
|
||||
optional: false,
|
||||
prep: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
id: "step_1",
|
||||
instruction: "Combine flour and water.",
|
||||
equipment_ids: [],
|
||||
},
|
||||
{
|
||||
id: "step_2",
|
||||
instruction: "Bake at 450F.",
|
||||
equipment_ids: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(structure.save_version).toBe(2);
|
||||
expect(structure.components[0].items).toHaveLength(2);
|
||||
expect(structure.steps).toHaveLength(2);
|
||||
|
||||
// Verify new ingredient was created
|
||||
const water = db.prepare("SELECT * FROM ingredients WHERE id = ?").get("water") as any;
|
||||
expect(water).toBeDefined();
|
||||
expect(water.name).toBe("Filtered Water");
|
||||
});
|
||||
|
||||
it("rejects save without components or steps", () => {
|
||||
expect(() =>
|
||||
saveRecipeStructure(db, "bread", {
|
||||
save_version: 1,
|
||||
components: [],
|
||||
steps: [{ id: "step_1", instruction: "Mix.", equipment_ids: [] }],
|
||||
})
|
||||
).toThrow(/at least one component/);
|
||||
|
||||
expect(() =>
|
||||
saveRecipeStructure(db, "bread", {
|
||||
save_version: 1,
|
||||
components: [{ id: "c1", name: "Dough", items: [{ id: "l1", ingredient_id: "flour", quantity: 100, unit_id: "gram", basis_member: false, optional: false, prep: [] }] }],
|
||||
steps: [],
|
||||
})
|
||||
).toThrow(/at least one preparation step/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recipe duplication", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("creates a deep clone with a unique ID and duplicate title", () => {
|
||||
const copyId = duplicateRecipe(db, "bread");
|
||||
expect(copyId).toBe("bread_copy");
|
||||
|
||||
const copy = editableRecipe(db, copyId);
|
||||
expect(copy).toBeDefined();
|
||||
expect(copy?.title).toBe("Country Bread Copy");
|
||||
expect(copy?.save_version).toBe(1);
|
||||
|
||||
const copyStruct = recipeStructure(db, copyId);
|
||||
expect(copyStruct?.components).toHaveLength(1);
|
||||
expect(copyStruct?.steps).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recipe quality metrics", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("identifies placeholder steps and unpriced items", () => {
|
||||
// Add a placeholder step
|
||||
db.prepare(
|
||||
"INSERT INTO recipe_steps(recipe_id, id, position, instruction, critical_control_point) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("bread", "step_todo", 2, "TODO: define baking temperature", 0);
|
||||
|
||||
const rows = recipeQualityRows(db);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].placeholder_steps).toBe(1);
|
||||
expect(rows[0].unpriced_items).toBe(1); // flour has no purchase items yet
|
||||
expect(rows[0].total_items).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive lifecycle & safe hard deletion", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("restores soft-deleted items across multiple entity types", () => {
|
||||
// Soft delete bread recipe and flour ingredient
|
||||
db.prepare("UPDATE recipes SET deleted_at = '2026-08-01' WHERE id = 'bread'").run();
|
||||
db.prepare("UPDATE ingredients SET deleted_at = '2026-08-01', status = 'archived' WHERE id = 'flour'").run();
|
||||
|
||||
restoreArchivedItems(db, [
|
||||
{ id: "bread", type: "recipe" },
|
||||
{ id: "flour", type: "ingredient" },
|
||||
]);
|
||||
|
||||
const bread = db.prepare("SELECT deleted_at FROM recipes WHERE id = 'bread'").get() as any;
|
||||
const flour = db.prepare("SELECT deleted_at, status FROM ingredients WHERE id = 'flour'").get() as any;
|
||||
|
||||
expect(bread.deleted_at).toBeNull();
|
||||
expect(flour.deleted_at).toBeNull();
|
||||
expect(flour.status).toBe("active");
|
||||
});
|
||||
|
||||
it("blocks permanent deletion of ingredients used in active recipes", () => {
|
||||
// Flour is used in active bread recipe
|
||||
expect(() =>
|
||||
permanentlyDeleteArchivedItems(db, [{ id: "flour", type: "ingredient" }])
|
||||
).toThrow(/currently used in active recipe/);
|
||||
});
|
||||
|
||||
it("permanently purges items when no active dependencies exist", () => {
|
||||
// Create an unreferenced ingredient
|
||||
db.prepare(
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("salt", 2, "Kosher Salt", "archived", "[]", "[]", "{}", "2026-08-01");
|
||||
|
||||
permanentlyDeleteArchivedItems(db, [{ id: "salt", type: "ingredient" }]);
|
||||
|
||||
const salt = db.prepare("SELECT 1 FROM ingredients WHERE id = 'salt'").get();
|
||||
expect(salt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+104
-1
@@ -45,6 +45,7 @@ export function saveRecipeMetadata(database: DatabaseSync, id: string, expectedV
|
||||
|
||||
export type RecipeStructure = {
|
||||
save_version: number;
|
||||
new_ingredients?: Array<{ id: string; name: string }>;
|
||||
metadata?: {
|
||||
title: string; yield_quantity: number; yield_unit_id: string; yield_servings: number | null; yield_basis: string | null;
|
||||
station?: string | null; cover_media_url?: string | null; tags?: string[];
|
||||
@@ -70,6 +71,13 @@ export function saveRecipeStructure(database: DatabaseSync, id: string, structur
|
||||
if (current.save_version !== structure.save_version) throw new Error("This recipe changed in another tab. Reload before saving.");
|
||||
if (!structure.components.length) throw new Error("A recipe needs at least one component.");
|
||||
if (!structure.steps.length) throw new Error("A recipe needs at least one preparation step.");
|
||||
const newIngredients = new Map<string,string>();
|
||||
for (const ingredient of structure.new_ingredients ?? []) {
|
||||
const ingredientId=ingredient.id.trim(),name=ingredient.name.trim();
|
||||
if(!/^[a-z0-9][a-z0-9_]*$/.test(ingredientId)||!name||newIngredients.has(ingredientId))throw new Error("Imported ingredients need unique names and stable IDs.");
|
||||
if(database.prepare("SELECT 1 FROM ingredients WHERE id=?").get(ingredientId))throw new Error(`Ingredient ${ingredientId} already exists. Reload and try again.`);
|
||||
newIngredients.set(ingredientId,name);
|
||||
}
|
||||
if (structure.metadata) {
|
||||
if (!structure.metadata.title.trim()) throw new Error("Recipe name is required.");
|
||||
if (!Number.isFinite(structure.metadata.yield_quantity) || structure.metadata.yield_quantity <= 0) throw new Error("Total yield must be greater than zero.");
|
||||
@@ -90,7 +98,7 @@ export function saveRecipeStructure(database: DatabaseSync, id: string, structur
|
||||
if (!Number.isFinite(item.quantity) || item.quantity <= 0) throw new Error(`${item.id} needs a positive quantity.`);
|
||||
if(item.nutrition_retention_factor!=null&&(!Number.isFinite(item.nutrition_retention_factor)||item.nutrition_retention_factor<0||item.nutrition_retention_factor>1))throw new Error(`${item.id} nutrition retention must be between 0 and 1.`);
|
||||
if (!database.prepare("SELECT 1 FROM units WHERE id = ?").get(item.unit_id)) throw new Error(`${item.id} uses an unknown unit.`);
|
||||
if (item.ingredient_id && !database.prepare("SELECT 1 FROM ingredients WHERE id = ?").get(item.ingredient_id)) throw new Error(`${item.id} references an unknown ingredient.`);
|
||||
if (item.ingredient_id && !newIngredients.has(item.ingredient_id) && !database.prepare("SELECT 1 FROM ingredients WHERE id = ?").get(item.ingredient_id)) throw new Error(`${item.id} references an unknown ingredient.`);
|
||||
if (item.subrecipe_id && (!database.prepare("SELECT 1 FROM recipes WHERE id = ?").get(item.subrecipe_id) || item.subrecipe_id === id)) throw new Error(`${item.id} references an invalid sub-recipe.`);
|
||||
}
|
||||
}
|
||||
@@ -102,6 +110,8 @@ export function saveRecipeStructure(database: DatabaseSync, id: string, structur
|
||||
const nextVersion = current.save_version + 1;
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const ingredientInsert=database.prepare("INSERT INTO ingredients(id,schema_version,name,status,categories_json,tags_json,source_json) VALUES (?,2,?,'active','[]','[]',?)");
|
||||
for(const [ingredientId,name] of newIngredients)ingredientInsert.run(ingredientId,name,JSON.stringify({source_type:"ai_import",title:"Recipe ingredient import",reviewed:false}));
|
||||
database.prepare("DELETE FROM recipe_steps WHERE recipe_id = ?").run(id);
|
||||
database.prepare("DELETE FROM recipe_components WHERE recipe_id = ?").run(id);
|
||||
const componentInsert = database.prepare("INSERT INTO recipe_components(recipe_id, id, position, name, notes_json) VALUES (?, ?, ?, ?, ?)");
|
||||
@@ -170,3 +180,96 @@ export function recipeQualityRows(database: DatabaseSync): QualityRecipe[] {
|
||||
ORDER BY r.title
|
||||
`).all() as unknown as QualityRecipe[];
|
||||
}
|
||||
|
||||
export function restoreArchivedItems(
|
||||
database: DatabaseSync,
|
||||
items: Array<{ id: string; type: string }>
|
||||
) {
|
||||
const tables: Record<string, string> = {
|
||||
recipe: "recipes",
|
||||
ingredient: "ingredients",
|
||||
book: "collections",
|
||||
purchase: "purchase_items",
|
||||
};
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
for (const item of items) {
|
||||
const table = tables[item.type];
|
||||
if (table) {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE ${table} SET deleted_at = NULL${
|
||||
item.type === "ingredient" ? ", status = 'active'" : ""
|
||||
} WHERE id = ?`
|
||||
)
|
||||
.run(item.id);
|
||||
}
|
||||
}
|
||||
database.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
refreshSiteProjection(database);
|
||||
}
|
||||
|
||||
export function permanentlyDeleteArchivedItems(
|
||||
database: DatabaseSync,
|
||||
items: Array<{ id: string; type: string }>
|
||||
) {
|
||||
// Check dependencies first
|
||||
for (const item of items) {
|
||||
if (item.type === "ingredient") {
|
||||
const activeRecipe = database
|
||||
.prepare(
|
||||
`SELECT r.title FROM recipe_items ri
|
||||
JOIN recipes r ON r.id = ri.recipe_id
|
||||
WHERE ri.ingredient_id = ? AND r.deleted_at IS NULL
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(item.id) as { title: string } | undefined;
|
||||
if (activeRecipe) {
|
||||
throw new Error(
|
||||
`Cannot delete ingredient '${item.id}' because it is currently used in active recipe '${activeRecipe.title}'. Remove it from the recipe first.`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (item.type === "recipe") {
|
||||
const parentRecipe = database
|
||||
.prepare(
|
||||
`SELECT r.title FROM recipe_items ri
|
||||
JOIN recipes r ON r.id = ri.recipe_id
|
||||
WHERE ri.subrecipe_id = ? AND r.deleted_at IS NULL
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(item.id) as { title: string } | undefined;
|
||||
if (parentRecipe) {
|
||||
throw new Error(
|
||||
`Cannot delete recipe '${item.id}' because it is used as a sub-recipe in active recipe '${parentRecipe.title}'.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tables: Record<string, string> = {
|
||||
recipe: "recipes",
|
||||
ingredient: "ingredients",
|
||||
book: "collections",
|
||||
purchase: "purchase_items",
|
||||
};
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
for (const item of items) {
|
||||
const table = tables[item.type];
|
||||
if (table) {
|
||||
database.prepare(`DELETE FROM ${table} WHERE id = ?`).run(item.id);
|
||||
}
|
||||
}
|
||||
database.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
refreshSiteProjection(database);
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { number, roundForDisplay } from "./format";
|
||||
import { number, roundForDisplay, titleCase } from "./format";
|
||||
|
||||
describe("display number formatting", () => {
|
||||
it("uses two decimal places for ordinary values", () => {
|
||||
@@ -17,3 +17,14 @@ describe("display number formatting", () => {
|
||||
expect(number(0)).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("title case formatting", () => {
|
||||
it("capitalizes the first letter of each word without lowercasing source data", () => {
|
||||
expect(titleCase("baking powder, double-acting")).toBe("Baking Powder, Double-acting");
|
||||
expect(titleCase("USDA choice beef")).toBe("USDA Choice Beef");
|
||||
});
|
||||
|
||||
it("preserves punctuation and whitespace-separated numeric tokens", () => {
|
||||
expect(titleCase("2% milk")).toBe("2% Milk");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,17 @@ export function number(value: number) {
|
||||
return new Intl.NumberFormat("en-US", { maximumFractionDigits: displayDigits(value) }).format(value);
|
||||
}
|
||||
|
||||
export function titleCase(input: string) {
|
||||
return input
|
||||
.split(/\s+/)
|
||||
.map((word) => {
|
||||
const index = word.search(/\p{L}/u);
|
||||
if (index === -1) return word;
|
||||
return word.slice(0, index) + word[index].toLocaleUpperCase() + word.slice(index + 1);
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function amount(value: Amount, units: Map<string, Unit>) {
|
||||
if (value.display) return value.display;
|
||||
const unit = units.get(value.unit_id);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeIngredientText, validateIngredientParse } from "./ingredient-parser";
|
||||
|
||||
describe("ingredient parser", () => {
|
||||
it("normalizes copied checklist text and Unicode fractions", () => {
|
||||
expect(normalizeIngredientText("▢1 pound beef\n☐ ½ cup water\n▢salt, , to taste"))
|
||||
.toBe("1 pound beef\n1/2 cup water\nsalt, to taste");
|
||||
});
|
||||
|
||||
it("validates a structured parser response", () => {
|
||||
expect(validateIngredientParse({
|
||||
components: [{ name:"Main", items:[{
|
||||
source_line:"1 pound beef", quantity:1, unit:"pound", ingredient:"ground beef",
|
||||
preparation:null, note:null, optional:false, alternatives:[],
|
||||
}] }], warnings:[],
|
||||
}, "1 pound beef")).toMatchObject({ normalized_text:"1 pound beef", components:[{name:"Main"}] });
|
||||
});
|
||||
|
||||
it("rejects invented or malformed quantities", () => {
|
||||
expect(() => validateIngredientParse({
|
||||
components: [{ name:"Main", items:[{
|
||||
source_line:"salt", quantity:-1, unit:null, ingredient:"salt",
|
||||
preparation:null, note:null, optional:false, alternatives:[],
|
||||
}] }], warnings:[],
|
||||
}, "salt")).toThrow("invalid quantity");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
export type ParsedIngredient = {
|
||||
source_line: string;
|
||||
quantity: number | null;
|
||||
unit: string | null;
|
||||
ingredient: string;
|
||||
preparation: string | null;
|
||||
note: string | null;
|
||||
optional: boolean;
|
||||
alternatives: string[];
|
||||
};
|
||||
|
||||
export type ParsedIngredientComponent = {
|
||||
name: string;
|
||||
items: ParsedIngredient[];
|
||||
};
|
||||
|
||||
export type IngredientParseResult = {
|
||||
normalized_text: string;
|
||||
components: ParsedIngredientComponent[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
const FRACTIONS: Record<string, string> = {
|
||||
"¼": "1/4", "½": "1/2", "¾": "3/4", "⅐": "1/7", "⅑": "1/9",
|
||||
"⅒": "1/10", "⅓": "1/3", "⅔": "2/3", "⅕": "1/5", "⅖": "2/5",
|
||||
"⅗": "3/5", "⅘": "4/5", "⅙": "1/6", "⅚": "5/6", "⅛": "1/8",
|
||||
"⅜": "3/8", "⅝": "5/8", "⅞": "7/8",
|
||||
};
|
||||
|
||||
export function normalizeIngredientText(value: string): string {
|
||||
return value
|
||||
.replace(/[¼½¾⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞]/g, (value) => FRACTIONS[value] ?? value)
|
||||
.normalize("NFKC")
|
||||
.replace(/⁄/g, "/")
|
||||
.replace(/[\u200B-\u200D\u2060\uFEFF]/g, "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line
|
||||
.replace(/^\s*(?:[▢□☐☑✓✔●•▪◦]|\[(?: |x|X)?\])\s*/, "")
|
||||
.replace(/\s*,\s*,+/g, ",")
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.replace(/\s+,/g, ",")
|
||||
.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const responseSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["components", "warnings"],
|
||||
properties: {
|
||||
components: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["name", "items"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["source_line", "quantity", "unit", "ingredient", "preparation", "note", "optional", "alternatives"],
|
||||
properties: {
|
||||
source_line: { type: "string" },
|
||||
quantity: { type: ["number", "null"] },
|
||||
unit: { type: ["string", "null"] },
|
||||
ingredient: { type: "string" },
|
||||
preparation: { type: ["string", "null"] },
|
||||
note: { type: ["string", "null"] },
|
||||
optional: { type: "boolean" },
|
||||
alternatives: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
warnings: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const systemPrompt = `You are a purpose-built culinary ingredient parser. Convert normalized recipe ingredient text into JSON only.
|
||||
|
||||
Rules:
|
||||
- Preserve the meaning and never invent an ingredient, amount, unit, or preparation.
|
||||
- Convert fractions and mixed numbers to decimal quantities.
|
||||
- Use singular conventional unit names such as gram, ounce, pound, teaspoon, tablespoon, cup, milliliter, liter, or each.
|
||||
- A line ending in a colon is a component header. Use "Main" when there is no header.
|
||||
- Group consecutive lines under one component. Do not create a new Main component for each line.
|
||||
- ingredient contains only the ingredient identity, never its quantity, unit, size, preparation, or note. For example, "15 ounces tomato sauce" becomes quantity 15, unit "ounce", ingredient "tomato sauce".
|
||||
- Treat sizes such as small, medium, and large as preparation or notes and use unit "each". For example, "1 medium onion, chopped" becomes quantity 1, unit "each", ingredient "onion", preparation "medium; chopped".
|
||||
- Split a source line containing two independently required ingredients into two items, retaining the same source_line.
|
||||
- Specifically, "salt and freshly ground black pepper, to taste" becomes separate salt and black pepper items.
|
||||
- Keep alternatives in alternatives instead of adding them as required items.
|
||||
- Put physical treatment such as chopped, minced, sliced, freshly ground, dried, or drained in preparation.
|
||||
- Put serving instructions, "to taste", temperatures, and other qualifications in note.
|
||||
- Set optional true when the source explicitly says optional.
|
||||
- Use null quantity and unit when the source supplies none. Do not guess.
|
||||
- Use null rather than an empty string. Do not repeat alternatives in note.
|
||||
- Retain every source line. Add a warning for ambiguity.`;
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
export function validateIngredientParse(value: unknown, normalizedText: string): IngredientParseResult {
|
||||
if (!value || typeof value !== "object") throw new Error("The parser returned an invalid document.");
|
||||
const source = value as Record<string, unknown>;
|
||||
if (!Array.isArray(source.components) || !Array.isArray(source.warnings)) throw new Error("The parser response is missing components or warnings.");
|
||||
const components = source.components.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") throw new Error("The parser returned an invalid component.");
|
||||
const component = entry as Record<string, unknown>;
|
||||
if (typeof component.name !== "string" || !component.name.trim() || !Array.isArray(component.items)) throw new Error("The parser returned an invalid component.");
|
||||
const items = component.items.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") throw new Error("The parser returned an invalid ingredient.");
|
||||
const item = entry as Record<string, unknown>;
|
||||
if (typeof item.source_line !== "string") throw new Error("The parser returned an ingredient without its source line.");
|
||||
if (typeof item.ingredient !== "string" || !item.ingredient.trim()) throw new Error(`The parser returned an unnamed ingredient for: ${item.source_line}`);
|
||||
if (!(item.quantity === null || typeof item.quantity === "number" && Number.isFinite(item.quantity) && item.quantity > 0)) throw new Error(`The parser returned an invalid quantity for: ${item.source_line}`);
|
||||
if (!isNullableString(item.unit) || !isNullableString(item.preparation) || !isNullableString(item.note)) throw new Error(`The parser returned invalid text fields for: ${item.source_line}`);
|
||||
if (typeof item.optional !== "boolean" || !Array.isArray(item.alternatives) || !item.alternatives.every((value) => typeof value === "string")) throw new Error(`The parser returned invalid qualifications for: ${item.source_line}`);
|
||||
const sourceLine = item.source_line.trim();
|
||||
const sourceSaysOptional = /\boptional\b/i.test(sourceLine);
|
||||
const alternatives = (item.alternatives as string[]).map((value) => value.trim()).filter((value) => value && !/^(?:none|optional)$/i.test(value));
|
||||
const note = item.note?.trim() || null;
|
||||
return {
|
||||
source_line: sourceLine,
|
||||
quantity: item.quantity as number | null,
|
||||
unit: item.unit?.trim().toLowerCase() || null,
|
||||
ingredient: item.ingredient.trim(),
|
||||
preparation: item.preparation?.trim() || null,
|
||||
note: note && (!/^optional$/i.test(note) || sourceSaysOptional) ? note : null,
|
||||
optional: sourceSaysOptional,
|
||||
alternatives,
|
||||
} satisfies ParsedIngredient;
|
||||
});
|
||||
return { name: component.name.trim(), items };
|
||||
}).filter((component) => component.items.length > 0);
|
||||
if (!components.length) throw new Error("The parser did not find any ingredients.");
|
||||
if (!source.warnings.every((value) => typeof value === "string")) throw new Error("The parser returned invalid warnings.");
|
||||
const consolidated: ParsedIngredientComponent[] = [];
|
||||
for (const component of components) {
|
||||
const previous = consolidated.at(-1);
|
||||
if (previous?.name.toLowerCase() === component.name.toLowerCase()) previous.items.push(...component.items);
|
||||
else consolidated.push(component);
|
||||
}
|
||||
return { normalized_text: normalizedText, components: consolidated, warnings: source.warnings as string[] };
|
||||
}
|
||||
|
||||
export async function parseIngredientsWithOllama(text: string): Promise<IngredientParseResult> {
|
||||
const normalizedText = normalizeIngredientText(text);
|
||||
if (!normalizedText) throw new Error("Enter at least one ingredient.");
|
||||
const endpoint = process.env.FORMULATION_OLLAMA_URL ?? "http://10.0.10.211:11434/api/chat";
|
||||
const model = process.env.FORMULATION_INGREDIENT_PARSER_MODEL ?? "qwen3:4b-instruct";
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
signal: AbortSignal.timeout(90_000),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
stream: false,
|
||||
think: false,
|
||||
format: responseSchema,
|
||||
options: { temperature: 0, num_predict: 6000 },
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: normalizedText },
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Ingredient parser service failed (${response.status}).`);
|
||||
const payload = await response.json() as { message?: { content?: string } };
|
||||
const content = payload.message?.content?.trim();
|
||||
if (!content) throw new Error("Ingredient parser returned an empty response.");
|
||||
let parsed: unknown;
|
||||
const unwrapped = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
||||
const firstBrace = unwrapped.indexOf("{");
|
||||
const lastBrace = unwrapped.lastIndexOf("}");
|
||||
const json = firstBrace >= 0 && lastBrace > firstBrace ? unwrapped.slice(firstBrace, lastBrace + 1) : unwrapped;
|
||||
try { parsed = JSON.parse(json); }
|
||||
catch { throw new Error("Ingredient parser returned malformed JSON."); }
|
||||
return validateIngredientParse(parsed, normalizedText);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import {
|
||||
createInventoryCount,
|
||||
getInventoryCountDetail,
|
||||
getInventoryCounts,
|
||||
getInventoryLocations,
|
||||
saveInventoryCountItems,
|
||||
} from "./repository/inventory-repository";
|
||||
|
||||
function createTestDatabase(): DatabaseSync {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
const schemaPath = path.resolve(process.cwd(), "migrations", "001_initial.sql");
|
||||
db.exec(fs.readFileSync(schemaPath, "utf8"));
|
||||
const invSchemaPath = path.resolve(process.cwd(), "migrations", "003_inventory.sql");
|
||||
db.exec(fs.readFileSync(invSchemaPath, "utf8"));
|
||||
|
||||
// Seed baseline units
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"gram", "Gram", "g", "mass", "metric", null, null, null
|
||||
);
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"each", "Each", "ea", "count", "customary", null, null, null
|
||||
);
|
||||
|
||||
// Seed ingredients
|
||||
db.prepare(
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("butter", 2, "Unsalted Butter", "active", "[]", "[]", "{}");
|
||||
db.prepare(
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("flour", 2, "Bread Flour", "active", "[]", "[]", "{}");
|
||||
|
||||
// Seed purchase item for butter: $5.00 for 500g ($0.01/g)
|
||||
db.prepare(
|
||||
"INSERT INTO purchase_items(id, ingredient_id, name, status, package_quantity, package_unit_id, units_per_case, usable_yield_factor) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("pi_butter", "butter", "Butter 500g", "active", 500, "gram", 1, 1);
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO price_observations(purchase_item_id, effective_at, currency, amount, source_json) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("pi_butter", "2026-08-01", "USD", 5.0, "{}");
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("inventory repository", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("fetches seeded inventory locations", () => {
|
||||
const locations = getInventoryLocations(db);
|
||||
expect(locations.length).toBeGreaterThanOrEqual(4);
|
||||
expect(locations.map((l) => l.name)).toContain("Walk-in Cooler");
|
||||
expect(locations.map((l) => l.name)).toContain("Dry Storage");
|
||||
});
|
||||
|
||||
it("creates a count session with pre-populated active ingredients", () => {
|
||||
const countId = createInventoryCount(db, {
|
||||
title: "End of Month Count - August 2026",
|
||||
counted_at: "2026-08-31",
|
||||
notes: "Routine inventory count",
|
||||
prepopulate: true,
|
||||
});
|
||||
|
||||
expect(countId).toBeDefined();
|
||||
|
||||
const detail = getInventoryCountDetail(db, countId);
|
||||
expect(detail).toBeDefined();
|
||||
expect(detail?.title).toBe("End of Month Count - August 2026");
|
||||
expect(detail?.status).toBe("open");
|
||||
expect(detail?.items).toHaveLength(2); // butter and flour
|
||||
});
|
||||
|
||||
it("saves count quantities, calculates extended valuation, and marks completed", () => {
|
||||
const countId = createInventoryCount(db, {
|
||||
title: "Weekly Count",
|
||||
counted_at: "2026-08-17",
|
||||
prepopulate: true,
|
||||
});
|
||||
|
||||
const locations = getInventoryLocations(db);
|
||||
const walkIn = locations.find((l) => l.name === "Walk-in Cooler")!;
|
||||
const dryStorage = locations.find((l) => l.name === "Dry Storage")!;
|
||||
|
||||
// Count 1000g of butter ($0.01/g = $10.00) in walk-in, and 2000g of flour (unpriced = $0.00) in dry storage
|
||||
saveInventoryCountItems(
|
||||
db,
|
||||
countId,
|
||||
[
|
||||
{
|
||||
location_id: walkIn.id,
|
||||
ingredient_id: "butter",
|
||||
quantity: 1000,
|
||||
unit_id: "gram",
|
||||
},
|
||||
{
|
||||
location_id: dryStorage.id,
|
||||
ingredient_id: "flour",
|
||||
quantity: 2000,
|
||||
unit_id: "gram",
|
||||
},
|
||||
],
|
||||
"completed"
|
||||
);
|
||||
|
||||
const detail = getInventoryCountDetail(db, countId);
|
||||
expect(detail?.status).toBe("completed");
|
||||
expect(detail?.total_value).toBe(10.0);
|
||||
|
||||
const counts = getInventoryCounts(db);
|
||||
expect(counts).toHaveLength(1);
|
||||
expect(counts[0].status).toBe("completed");
|
||||
expect(counts[0].total_value).toBe(10.0);
|
||||
expect(counts[0].item_count).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ingredients, units } from "./data";
|
||||
import { loadCatalogs } from "./data";
|
||||
import { convert, convertWithDensity, convertWithIngredientMeasures, densityInGramsPerMilliliter } from "./measurement";
|
||||
|
||||
const { ingredients, units } = loadCatalogs();
|
||||
|
||||
describe("normalized measurement conversion", () => {
|
||||
it("converts mass through canonical grams", () => {
|
||||
expect(convert(1, "pound", "ounce_mass", units)).toBeCloseTo(16, 8);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./types";
|
||||
export * from "./recipe-repository";
|
||||
export * from "./ingredient-repository";
|
||||
export * from "./inventory-repository";
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
DensityMeasurement,
|
||||
Ingredient,
|
||||
MeasureConversion,
|
||||
SourceReference,
|
||||
} from "../types";
|
||||
import type { IngredientRow } from "./types";
|
||||
import { titleCase } from "../format";
|
||||
|
||||
const defaultSource: SourceReference = {
|
||||
source_type: "manual",
|
||||
title: "Recipe application",
|
||||
reviewed: true,
|
||||
};
|
||||
|
||||
const safeJson = <T>(value: string | null | undefined, fallback: T): T => {
|
||||
if (!value) return fallback;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
export function getDomainIngredient(
|
||||
database: DatabaseSync,
|
||||
id: string
|
||||
): Ingredient | undefined {
|
||||
const row = database
|
||||
.prepare("SELECT * FROM ingredients WHERE id = ?")
|
||||
.get(id) as unknown as IngredientRow | undefined;
|
||||
if (!row) return undefined;
|
||||
|
||||
const aliases = database
|
||||
.prepare("SELECT name, kind FROM ingredient_aliases WHERE ingredient_id = ? ORDER BY name")
|
||||
.all(id) as Array<{ name: string; kind?: string }>;
|
||||
|
||||
const densities = (
|
||||
database
|
||||
.prepare("SELECT * FROM ingredient_density_measurements WHERE ingredient_id = ? ORDER BY id")
|
||||
.all(id) as any[]
|
||||
).map((value): DensityMeasurement => ({
|
||||
id: value.id,
|
||||
mass: { quantity: value.mass_quantity, unit_id: value.mass_unit_id },
|
||||
volume: { quantity: value.volume_quantity, unit_id: value.volume_unit_id },
|
||||
...(value.temperature_c != null ? { temperature_c: value.temperature_c } : {}),
|
||||
...(value.state ? { state: value.state } : {}),
|
||||
source: safeJson<SourceReference>(value.source_json, defaultSource),
|
||||
}));
|
||||
|
||||
const conversions = (
|
||||
database
|
||||
.prepare("SELECT * FROM ingredient_measure_conversions WHERE ingredient_id = ? ORDER BY id")
|
||||
.all(id) as any[]
|
||||
).map((value): MeasureConversion => ({
|
||||
id: value.id,
|
||||
from: { quantity: value.from_quantity, unit_id: value.from_unit_id },
|
||||
to: { quantity: value.to_quantity, unit_id: value.to_unit_id },
|
||||
...(value.state ? { state: value.state } : {}),
|
||||
source: safeJson<SourceReference>(value.source_json, defaultSource),
|
||||
}));
|
||||
|
||||
const prep = (
|
||||
database
|
||||
.prepare("SELECT action_id, yield_factor, notes FROM ingredient_prep_actions WHERE ingredient_id = ? ORDER BY action_id")
|
||||
.all(id) as any[]
|
||||
).map((value) => ({
|
||||
action_id: value.action_id,
|
||||
yield_factor: value.yield_factor,
|
||||
...(value.notes ? { notes: value.notes } : {}),
|
||||
}));
|
||||
|
||||
const mappings = database
|
||||
.prepare("SELECT id, mapping_type FROM source_mappings WHERE subject_type = 'ingredient' AND subject_id = ? AND status = 'reviewed' ORDER BY id")
|
||||
.all(id) as Array<{ id: string; mapping_type: string }>;
|
||||
|
||||
const source = safeJson<Record<string, any>>(row.source_json, {});
|
||||
|
||||
return {
|
||||
schema_version: 2,
|
||||
id: row.id,
|
||||
name: titleCase(row.name),
|
||||
...(row.description ? { description: row.description } : {}),
|
||||
status: row.status as Ingredient["status"],
|
||||
categories: safeJson<string[]>(row.categories_json, []),
|
||||
tags: safeJson<string[]>(row.tags_json, []),
|
||||
aliases,
|
||||
density_measurements: densities,
|
||||
measure_conversions: conversions,
|
||||
prep_actions: prep,
|
||||
nutrition_mapping_ids: mappings
|
||||
.filter((v) => v.mapping_type === "nutrition")
|
||||
.map((v) => v.id),
|
||||
allergen_mapping_ids: mappings
|
||||
.filter((v) => v.mapping_type === "allergen")
|
||||
.map((v) => v.id),
|
||||
...(source.shelf_life ? { shelf_life: source.shelf_life } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
export type InventoryLocation = {
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type InventoryCountSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
counted_at: string;
|
||||
status: "open" | "completed";
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
item_count: number;
|
||||
total_value: number;
|
||||
};
|
||||
|
||||
export type InventoryCountItem = {
|
||||
count_id: string;
|
||||
location_id: string | null;
|
||||
location_name: string | null;
|
||||
ingredient_id: string;
|
||||
ingredient_name: string;
|
||||
quantity: number;
|
||||
unit_id: string;
|
||||
unit_symbol: string;
|
||||
unit_cost: number | null;
|
||||
extended_cost: number | null;
|
||||
};
|
||||
|
||||
export type InventoryCountDetail = {
|
||||
id: string;
|
||||
title: string;
|
||||
counted_at: string;
|
||||
status: "open" | "completed";
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
total_value: number;
|
||||
locations: InventoryLocation[];
|
||||
items: InventoryCountItem[];
|
||||
};
|
||||
|
||||
export function getInventoryLocations(database: DatabaseSync): InventoryLocation[] {
|
||||
return database
|
||||
.prepare(
|
||||
"SELECT id, name, position FROM inventory_locations WHERE deleted_at IS NULL ORDER BY position, name"
|
||||
)
|
||||
.all() as unknown as InventoryLocation[];
|
||||
}
|
||||
|
||||
export function getInventoryCounts(database: DatabaseSync): InventoryCountSummary[] {
|
||||
const counts = database
|
||||
.prepare(
|
||||
`SELECT id, title, counted_at, status, notes, created_at
|
||||
FROM inventory_counts
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY counted_at DESC, created_at DESC`
|
||||
)
|
||||
.all() as unknown as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
counted_at: string;
|
||||
status: "open" | "completed";
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
}>;
|
||||
|
||||
return counts.map((count) => {
|
||||
const stats = database
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) as item_count,
|
||||
COALESCE(SUM(extended_cost), 0) as total_value
|
||||
FROM inventory_count_items
|
||||
WHERE count_id = ?`
|
||||
)
|
||||
.get(count.id) as { item_count: number; total_value: number } | undefined;
|
||||
|
||||
return {
|
||||
...count,
|
||||
item_count: stats?.item_count ?? 0,
|
||||
total_value: Math.round((stats?.total_value ?? 0) * 100) / 100,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getInventoryCountDetail(
|
||||
database: DatabaseSync,
|
||||
id: string
|
||||
): InventoryCountDetail | undefined {
|
||||
const count = database
|
||||
.prepare(
|
||||
"SELECT id, title, counted_at, status, notes, created_at FROM inventory_counts WHERE id = ? AND deleted_at IS NULL"
|
||||
)
|
||||
.get(id) as unknown as
|
||||
| {
|
||||
id: string;
|
||||
title: string;
|
||||
counted_at: string;
|
||||
status: "open" | "completed";
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!count) return undefined;
|
||||
|
||||
const locations = getInventoryLocations(database);
|
||||
|
||||
const rawItems = database
|
||||
.prepare(
|
||||
`SELECT
|
||||
ci.count_id,
|
||||
ci.location_id,
|
||||
loc.name as location_name,
|
||||
ci.ingredient_id,
|
||||
ing.name as ingredient_name,
|
||||
ci.quantity,
|
||||
ci.unit_id,
|
||||
u.symbol as unit_symbol,
|
||||
ci.unit_cost,
|
||||
ci.extended_cost
|
||||
FROM inventory_count_items ci
|
||||
JOIN ingredients ing ON ing.id = ci.ingredient_id
|
||||
LEFT JOIN inventory_locations loc ON loc.id = ci.location_id
|
||||
LEFT JOIN units u ON u.id = ci.unit_id
|
||||
WHERE ci.count_id = ?
|
||||
ORDER BY COALESCE(loc.position, 999), ing.name`
|
||||
)
|
||||
.all(id) as unknown as Array<{
|
||||
count_id: string;
|
||||
location_id: string | null;
|
||||
location_name: string | null;
|
||||
ingredient_id: string;
|
||||
ingredient_name: string;
|
||||
quantity: number;
|
||||
unit_id: string;
|
||||
unit_symbol: string | null;
|
||||
unit_cost: number | null;
|
||||
extended_cost: number | null;
|
||||
}>;
|
||||
|
||||
const items: InventoryCountItem[] = rawItems.map((row) => ({
|
||||
...row,
|
||||
unit_symbol: row.unit_symbol || row.unit_id,
|
||||
}));
|
||||
|
||||
const total_value = items.reduce(
|
||||
(sum, item) => sum + (item.extended_cost ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
...count,
|
||||
total_value: Math.round(total_value * 100) / 100,
|
||||
locations,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function createInventoryCount(
|
||||
database: DatabaseSync,
|
||||
data: {
|
||||
title: string;
|
||||
counted_at: string;
|
||||
notes?: string;
|
||||
prepopulate?: boolean;
|
||||
}
|
||||
): string {
|
||||
const base = `count_${data.counted_at.replace(/[^0-9]/g, "")}`;
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (database.prepare("SELECT 1 FROM inventory_counts WHERE id = ?").get(id)) {
|
||||
id = `${base}_${suffix++}`;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
database
|
||||
.prepare(
|
||||
"INSERT INTO inventory_counts (id, title, counted_at, status, notes, created_at) VALUES (?, ?, ?, 'open', ?, ?)"
|
||||
)
|
||||
.run(id, data.title.trim(), data.counted_at, data.notes?.trim() || null, now);
|
||||
|
||||
if (data.prepopulate !== false) {
|
||||
// Prepopulate active ingredients with zero quantities
|
||||
const defaultLocation = database
|
||||
.prepare("SELECT id FROM inventory_locations WHERE deleted_at IS NULL ORDER BY position LIMIT 1")
|
||||
.get() as { id: string } | undefined;
|
||||
|
||||
const ingredients = database
|
||||
.prepare(
|
||||
`SELECT i.id, i.name
|
||||
FROM ingredients i
|
||||
WHERE i.status = 'active' AND i.deleted_at IS NULL
|
||||
ORDER BY i.name`
|
||||
)
|
||||
.all() as Array<{ id: string; name: string }>;
|
||||
|
||||
const insertItem = database.prepare(
|
||||
`INSERT OR IGNORE INTO inventory_count_items
|
||||
(count_id, location_id, ingredient_id, quantity, unit_id, unit_cost, extended_cost)
|
||||
VALUES (?, ?, ?, 0, 'gram', 0, 0)`
|
||||
);
|
||||
|
||||
for (const ingredient of ingredients) {
|
||||
insertItem.run(id, defaultLocation?.id ?? null, ingredient.id);
|
||||
}
|
||||
}
|
||||
|
||||
database.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export function saveInventoryCountItems(
|
||||
database: DatabaseSync,
|
||||
countId: string,
|
||||
items: Array<{
|
||||
location_id: string | null;
|
||||
ingredient_id: string;
|
||||
quantity: number;
|
||||
unit_id: string;
|
||||
}>,
|
||||
status?: "open" | "completed"
|
||||
) {
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
// Delete existing items and rebuild cleanly
|
||||
database.prepare("DELETE FROM inventory_count_items WHERE count_id = ?").run(countId);
|
||||
|
||||
const insert = database.prepare(
|
||||
`INSERT INTO inventory_count_items (count_id, location_id, ingredient_id, quantity, unit_id, unit_cost, extended_cost)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
for (const item of items) {
|
||||
// Look up current purchase unit cost if available
|
||||
const priceRow = database
|
||||
.prepare(
|
||||
`SELECT (po.amount / (p.package_quantity * p.units_per_case)) as cost_per_unit
|
||||
FROM purchase_items p
|
||||
JOIN price_observations po ON po.purchase_item_id = p.id
|
||||
WHERE p.ingredient_id = ? AND p.status = 'active'
|
||||
ORDER BY po.effective_at DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(item.ingredient_id) as { cost_per_unit: number } | undefined;
|
||||
|
||||
const unit_cost = priceRow?.cost_per_unit ?? null;
|
||||
const extended_cost =
|
||||
unit_cost != null && Number.isFinite(item.quantity)
|
||||
? Math.round(item.quantity * unit_cost * 100) / 100
|
||||
: 0;
|
||||
|
||||
insert.run(
|
||||
countId,
|
||||
item.location_id,
|
||||
item.ingredient_id,
|
||||
item.quantity,
|
||||
item.unit_id,
|
||||
unit_cost,
|
||||
extended_cost
|
||||
);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
database
|
||||
.prepare("UPDATE inventory_counts SET status = ? WHERE id = ?")
|
||||
.run(status, countId);
|
||||
}
|
||||
|
||||
database.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
DensityMeasurement,
|
||||
Ingredient,
|
||||
MeasureConversion,
|
||||
PrepAction,
|
||||
PurchaseItem,
|
||||
Recipe,
|
||||
SourceMapping,
|
||||
SourceReference,
|
||||
Unit,
|
||||
} from "../types";
|
||||
import type {
|
||||
IngredientRow,
|
||||
ItemPrepActionRow,
|
||||
PriceObservationRow,
|
||||
PurchaseItemRow,
|
||||
RecipeCalculationContext,
|
||||
RecipeComponentRow,
|
||||
RecipeItemRow,
|
||||
RecipeMeasureConversionRow,
|
||||
RecipeRow,
|
||||
RecipeStepRow,
|
||||
SourceMappingRow,
|
||||
UnitRow,
|
||||
} from "./types";
|
||||
import { titleCase } from "../format";
|
||||
|
||||
const defaultSource: SourceReference = {
|
||||
source_type: "manual",
|
||||
title: "Recipe application",
|
||||
reviewed: true,
|
||||
};
|
||||
|
||||
const safeJson = <T>(value: string | null | undefined, fallback: T): T => {
|
||||
if (!value) return fallback;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
export type EditableRecipe = {
|
||||
id: string;
|
||||
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;
|
||||
source_json: string;
|
||||
};
|
||||
|
||||
export function getEditableRecipe(
|
||||
database: DatabaseSync,
|
||||
id: string
|
||||
): EditableRecipe | undefined {
|
||||
return database
|
||||
.prepare(
|
||||
"SELECT id, save_version, title, summary, categories_json, tags_json, yield_quantity, yield_unit_id, yield_servings, yield_basis, source_json FROM recipes WHERE id = ?"
|
||||
)
|
||||
.get(id) as unknown as EditableRecipe | undefined;
|
||||
}
|
||||
|
||||
export function buildDomainUnits(database: DatabaseSync): Map<string, Unit> {
|
||||
const rows = database
|
||||
.prepare("SELECT * FROM units ORDER BY id")
|
||||
.all() as unknown as UnitRow[];
|
||||
const map = new Map<string, Unit>();
|
||||
for (const row of rows) {
|
||||
map.set(row.id, {
|
||||
schema_version: 2,
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
symbol: row.symbol,
|
||||
dimension: row.dimension as Unit["dimension"],
|
||||
system: row.system as Unit["system"],
|
||||
...(row.base_unit_id
|
||||
? {
|
||||
base_conversion: {
|
||||
base_unit_id: row.base_unit_id,
|
||||
factor: row.factor ?? 1,
|
||||
...(row.offset != null ? { offset: row.offset } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function buildDomainPrepActions(database: DatabaseSync): Map<string, PrepAction> {
|
||||
const rows = database
|
||||
.prepare("SELECT * FROM prep_actions ORDER BY id")
|
||||
.all() as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
action_type: string;
|
||||
default_yield_factor: number | null;
|
||||
notes: string | null;
|
||||
}>;
|
||||
const map = new Map<string, PrepAction>();
|
||||
for (const row of rows) {
|
||||
map.set(row.id, {
|
||||
schema_version: 2,
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
action_type: row.action_type as PrepAction["action_type"],
|
||||
...(row.default_yield_factor != null
|
||||
? { default_yield_factor: row.default_yield_factor }
|
||||
: {}),
|
||||
...(row.notes ? { notes: row.notes } : {}),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function getDomainRecipe(
|
||||
database: DatabaseSync,
|
||||
id: string
|
||||
): Recipe | undefined {
|
||||
const row = database
|
||||
.prepare("SELECT * FROM recipes WHERE id = ?")
|
||||
.get(id) as unknown as RecipeRow | undefined;
|
||||
if (!row) return undefined;
|
||||
|
||||
const componentRows = database
|
||||
.prepare("SELECT * FROM recipe_components WHERE recipe_id = ? ORDER BY position")
|
||||
.all(id) as unknown as RecipeComponentRow[];
|
||||
const itemRows = database
|
||||
.prepare("SELECT * FROM recipe_items WHERE recipe_id = ? ORDER BY position")
|
||||
.all(id) as unknown as RecipeItemRow[];
|
||||
const prepRows = database
|
||||
.prepare("SELECT * FROM item_prep_actions WHERE recipe_id = ? ORDER BY position")
|
||||
.all(id) as unknown as ItemPrepActionRow[];
|
||||
const stepRows = database
|
||||
.prepare("SELECT * FROM recipe_steps WHERE recipe_id = ? ORDER BY position")
|
||||
.all(id) as unknown as RecipeStepRow[];
|
||||
const stepEquipmentRows = database
|
||||
.prepare("SELECT step_id, equipment_id FROM step_equipment WHERE recipe_id = ? ORDER BY equipment_id")
|
||||
.all(id) as Array<{ step_id: string; equipment_id: string }>;
|
||||
const recipeEquipmentRows = database
|
||||
.prepare("SELECT equipment_id FROM recipe_equipment WHERE recipe_id = ? ORDER BY equipment_id")
|
||||
.all(id) as Array<{ equipment_id: string }>;
|
||||
const conversionRows = database
|
||||
.prepare("SELECT * FROM recipe_measure_conversions WHERE recipe_id = ? ORDER BY id")
|
||||
.all(id) as unknown as RecipeMeasureConversionRow[];
|
||||
|
||||
const source = safeJson<Record<string, any>>(row.source_json, {});
|
||||
const oldComponents = new Map(
|
||||
(source.components ?? []).map((v: any) => [v.id, v])
|
||||
);
|
||||
const oldSteps = new Map((source.steps ?? []).map((v: any) => [v.id, v]));
|
||||
|
||||
const prepByItem = new Map<string, ItemPrepActionRow[]>();
|
||||
for (const prep of prepRows) {
|
||||
const list = prepByItem.get(prep.item_id) ?? [];
|
||||
list.push(prep);
|
||||
prepByItem.set(prep.item_id, list);
|
||||
}
|
||||
|
||||
const itemsByComponent = new Map<string, RecipeItemRow[]>();
|
||||
for (const item of itemRows) {
|
||||
const list = itemsByComponent.get(item.component_id) ?? [];
|
||||
list.push(item);
|
||||
itemsByComponent.set(item.component_id, list);
|
||||
}
|
||||
|
||||
const equipmentByStep = new Map<string, string[]>();
|
||||
for (const eq of stepEquipmentRows) {
|
||||
const list = equipmentByStep.get(eq.step_id) ?? [];
|
||||
list.push(eq.equipment_id);
|
||||
equipmentByStep.set(eq.step_id, list);
|
||||
}
|
||||
|
||||
const components = componentRows.map((comp) => {
|
||||
const old = oldComponents.get(comp.id) ?? {};
|
||||
const compItems = (itemsByComponent.get(comp.id) ?? []).map((item) => {
|
||||
const itemPrep = (prepByItem.get(item.id) ?? []).map((prep) => ({
|
||||
action_id: prep.action_id,
|
||||
...(prep.yield_factor != null ? { yield_factor: prep.yield_factor } : {}),
|
||||
...(prep.notes ? { notes: prep.notes } : {}),
|
||||
}));
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
reference: item.ingredient_id
|
||||
? { ingredient_id: item.ingredient_id }
|
||||
: { recipe_id: item.subrecipe_id! },
|
||||
amount: {
|
||||
quantity: item.quantity,
|
||||
unit_id: item.unit_id,
|
||||
},
|
||||
...(item.percentage != null ? { percentage: item.percentage } : {}),
|
||||
...(item.basis_member ? { basis_member: true as const } : {}),
|
||||
...(item.optional ? { optional: true as const } : {}),
|
||||
...(item.notes ? { notes: item.notes } : {}),
|
||||
...(item.nutrition_retention_factor !== 1
|
||||
? { nutrition_retention_factor: item.nutrition_retention_factor }
|
||||
: {}),
|
||||
...(itemPrep.length ? { prep: itemPrep } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...old,
|
||||
id: comp.id,
|
||||
name: comp.name,
|
||||
notes: safeJson<string[]>(comp.notes_json, []),
|
||||
items: compItems,
|
||||
};
|
||||
});
|
||||
|
||||
const steps = stepRows.map((step, index) => {
|
||||
const old = oldSteps.get(step.id) ?? {};
|
||||
const eqIds = equipmentByStep.get(step.id) ?? [];
|
||||
return {
|
||||
...old,
|
||||
id: step.id,
|
||||
order: index + 1,
|
||||
instruction: step.instruction,
|
||||
...(step.critical_control_point ? { critical_control_point: true as const } : {}),
|
||||
...(eqIds.length ? { equipment_ids: eqIds } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const measureConversions = conversionRows.map((conv) => ({
|
||||
id: conv.id,
|
||||
from: { quantity: conv.from_quantity, unit_id: conv.from_unit_id },
|
||||
to: { quantity: conv.to_quantity, unit_id: conv.to_unit_id },
|
||||
...(conv.notes ? { notes: conv.notes } : {}),
|
||||
source: safeJson<SourceReference>(conv.source_json, defaultSource),
|
||||
}));
|
||||
|
||||
const scaling = row.scaling_mode
|
||||
? {
|
||||
mode: row.scaling_mode as NonNullable<Recipe["scaling"]>["mode"],
|
||||
...(row.scaling_basis_id ? { basis_id: row.scaling_basis_id } : {}),
|
||||
...(row.scaling_basis_quantity != null && row.scaling_basis_unit_id
|
||||
? {
|
||||
basis_amount: {
|
||||
quantity: row.scaling_basis_quantity,
|
||||
unit_id: row.scaling_basis_unit_id,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const equipmentIds = recipeEquipmentRows.map((e) => e.equipment_id);
|
||||
|
||||
return {
|
||||
...source,
|
||||
schema_version: 2,
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
...(row.summary ? { summary: row.summary } : {}),
|
||||
categories: safeJson<string[]>(row.categories_json, []),
|
||||
tags: safeJson<string[]>(row.tags_json, []),
|
||||
...(row.station ? { station: row.station } : {}),
|
||||
...(row.auto_yield ? { auto_yield: true as const } : {}),
|
||||
yield: {
|
||||
...(source.yield ?? {}),
|
||||
amount: { quantity: row.yield_quantity, unit_id: row.yield_unit_id },
|
||||
...(row.yield_servings != null ? { servings: row.yield_servings } : {}),
|
||||
...(row.yield_basis ? { basis: row.yield_basis as Recipe["yield"]["basis"] } : {}),
|
||||
},
|
||||
...(scaling ? { scaling } : {}),
|
||||
components,
|
||||
steps,
|
||||
notes: safeJson<string[]>(row.notes_json, []),
|
||||
measure_conversions: measureConversions,
|
||||
...(equipmentIds.length ? { equipment_ids: equipmentIds } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRecipeCalculationContext(
|
||||
database: DatabaseSync,
|
||||
id: string
|
||||
): RecipeCalculationContext | undefined {
|
||||
const domainRecipe = getDomainRecipe(database, id);
|
||||
if (!domainRecipe) return undefined;
|
||||
|
||||
const recipeMap = new Map<string, Recipe>([[domainRecipe.id, domainRecipe]]);
|
||||
const ingredientIds = new Set<string>();
|
||||
const subrecipeIdsToVisit = new Set<string>();
|
||||
|
||||
const collectReferences = (rec: Recipe) => {
|
||||
for (const comp of rec.components) {
|
||||
for (const item of comp.items) {
|
||||
if ("ingredient_id" in item.reference) {
|
||||
ingredientIds.add(item.reference.ingredient_id);
|
||||
} else if ("recipe_id" in item.reference) {
|
||||
if (!recipeMap.has(item.reference.recipe_id)) {
|
||||
subrecipeIdsToVisit.add(item.reference.recipe_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
collectReferences(domainRecipe);
|
||||
|
||||
// Recursively collect all referenced subrecipes
|
||||
while (subrecipeIdsToVisit.size > 0) {
|
||||
const nextId = subrecipeIdsToVisit.values().next().value!;
|
||||
subrecipeIdsToVisit.delete(nextId);
|
||||
if (!recipeMap.has(nextId)) {
|
||||
const sub = getDomainRecipe(database, nextId);
|
||||
if (sub) {
|
||||
recipeMap.set(sub.id, sub);
|
||||
collectReferences(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ingredientMap = new Map<string, Ingredient>();
|
||||
if (ingredientIds.size > 0) {
|
||||
const placeholders = Array.from(ingredientIds).map(() => "?").join(",");
|
||||
const ingRows = database
|
||||
.prepare(`SELECT * FROM ingredients WHERE id IN (${placeholders})`)
|
||||
.all(...Array.from(ingredientIds)) as unknown as IngredientRow[];
|
||||
|
||||
const aliasRows = database
|
||||
.prepare(`SELECT ingredient_id, name, kind FROM ingredient_aliases WHERE ingredient_id IN (${placeholders}) ORDER BY name`)
|
||||
.all(...Array.from(ingredientIds)) as Array<{ ingredient_id: string; name: string; kind: string | null }>;
|
||||
|
||||
const densityRows = database
|
||||
.prepare(`SELECT * FROM ingredient_density_measurements WHERE ingredient_id IN (${placeholders}) ORDER BY id`)
|
||||
.all(...Array.from(ingredientIds)) as Array<any>;
|
||||
|
||||
const convRows = database
|
||||
.prepare(`SELECT * FROM ingredient_measure_conversions WHERE ingredient_id IN (${placeholders}) ORDER BY id`)
|
||||
.all(...Array.from(ingredientIds)) as Array<any>;
|
||||
|
||||
const prepRows = database
|
||||
.prepare(`SELECT * FROM ingredient_prep_actions WHERE ingredient_id IN (${placeholders}) ORDER BY action_id`)
|
||||
.all(...Array.from(ingredientIds)) as Array<any>;
|
||||
|
||||
const mappingIdRows = database
|
||||
.prepare(`SELECT id, subject_id, mapping_type FROM source_mappings WHERE subject_type = 'ingredient' AND subject_id IN (${placeholders}) AND status = 'reviewed' ORDER BY id`)
|
||||
.all(...Array.from(ingredientIds)) as Array<{ id: string; subject_id: string; mapping_type: string }>;
|
||||
|
||||
const aliasesByIng = new Map<string, Array<{ name: string; kind?: string }>>();
|
||||
for (const a of aliasRows) {
|
||||
const list = aliasesByIng.get(a.ingredient_id) ?? [];
|
||||
list.push({ name: a.name, ...(a.kind ? { kind: a.kind } : {}) });
|
||||
aliasesByIng.set(a.ingredient_id, list);
|
||||
}
|
||||
|
||||
const densitiesByIng = new Map<string, DensityMeasurement[]>();
|
||||
for (const d of densityRows) {
|
||||
const list = densitiesByIng.get(d.ingredient_id) ?? [];
|
||||
list.push({
|
||||
id: d.id,
|
||||
mass: { quantity: d.mass_quantity, unit_id: d.mass_unit_id },
|
||||
volume: { quantity: d.volume_quantity, unit_id: d.volume_unit_id },
|
||||
...(d.temperature_c != null ? { temperature_c: d.temperature_c } : {}),
|
||||
...(d.state ? { state: d.state } : {}),
|
||||
source: safeJson<SourceReference>(d.source_json, defaultSource),
|
||||
});
|
||||
densitiesByIng.set(d.ingredient_id, list);
|
||||
}
|
||||
|
||||
const convsByIng = new Map<string, MeasureConversion[]>();
|
||||
for (const c of convRows) {
|
||||
const list = convsByIng.get(c.ingredient_id) ?? [];
|
||||
list.push({
|
||||
id: c.id,
|
||||
from: { quantity: c.from_quantity, unit_id: c.from_unit_id },
|
||||
to: { quantity: c.to_quantity, unit_id: c.to_unit_id },
|
||||
...(c.state ? { state: c.state } : {}),
|
||||
source: safeJson<SourceReference>(c.source_json, defaultSource),
|
||||
});
|
||||
convsByIng.set(c.ingredient_id, list);
|
||||
}
|
||||
|
||||
const prepByIng = new Map<string, any[]>();
|
||||
for (const p of prepRows) {
|
||||
const list = prepByIng.get(p.ingredient_id) ?? [];
|
||||
list.push({
|
||||
action_id: p.action_id,
|
||||
yield_factor: p.yield_factor,
|
||||
...(p.notes ? { notes: p.notes } : {}),
|
||||
});
|
||||
prepByIng.set(p.ingredient_id, list);
|
||||
}
|
||||
|
||||
const mappingsByIng = new Map<string, { nutrition: string[]; allergen: string[] }>();
|
||||
for (const m of mappingIdRows) {
|
||||
const entry = mappingsByIng.get(m.subject_id) ?? { nutrition: [], allergen: [] };
|
||||
if (m.mapping_type === "nutrition") entry.nutrition.push(m.id);
|
||||
if (m.mapping_type === "allergen") entry.allergen.push(m.id);
|
||||
mappingsByIng.set(m.subject_id, entry);
|
||||
}
|
||||
|
||||
for (const row of ingRows) {
|
||||
const source = safeJson<Record<string, any>>(row.source_json, {});
|
||||
const mapping = mappingsByIng.get(row.id) ?? { nutrition: [], allergen: [] };
|
||||
|
||||
ingredientMap.set(row.id, {
|
||||
schema_version: 2,
|
||||
id: row.id,
|
||||
name: titleCase(row.name),
|
||||
...(row.description ? { description: row.description } : {}),
|
||||
status: row.status as Ingredient["status"],
|
||||
categories: safeJson<string[]>(row.categories_json, []),
|
||||
tags: safeJson<string[]>(row.tags_json, []),
|
||||
aliases: aliasesByIng.get(row.id) ?? [],
|
||||
density_measurements: densitiesByIng.get(row.id) ?? [],
|
||||
measure_conversions: convsByIng.get(row.id) ?? [],
|
||||
prep_actions: prepByIng.get(row.id) ?? [],
|
||||
nutrition_mapping_ids: mapping.nutrition,
|
||||
allergen_mapping_ids: mapping.allergen,
|
||||
...(source.shelf_life ? { shelf_life: source.shelf_life } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch purchase items and prices for referenced ingredients
|
||||
const purchaseItemsMap = new Map<string, PurchaseItem>();
|
||||
if (ingredientIds.size > 0) {
|
||||
const placeholders = Array.from(ingredientIds).map(() => "?").join(",");
|
||||
const purchaseRows = database
|
||||
.prepare(`SELECT * FROM purchase_items WHERE ingredient_id IN (${placeholders}) ORDER BY id`)
|
||||
.all(...Array.from(ingredientIds)) as unknown as PurchaseItemRow[];
|
||||
|
||||
if (purchaseRows.length > 0) {
|
||||
const purchaseIds = purchaseRows.map((p) => p.id);
|
||||
const pPlaceholders = purchaseIds.map(() => "?").join(",");
|
||||
const priceRows = database
|
||||
.prepare(`SELECT * FROM price_observations WHERE purchase_item_id IN (${pPlaceholders}) ORDER BY effective_at, currency`)
|
||||
.all(...purchaseIds) as unknown as PriceObservationRow[];
|
||||
|
||||
const pricesByPurchase = new Map<string, Array<{ amount: number; currency: string; effective_at: string; source: SourceReference }>>();
|
||||
for (const pr of priceRows) {
|
||||
const list = pricesByPurchase.get(pr.purchase_item_id) ?? [];
|
||||
list.push({
|
||||
amount: pr.amount,
|
||||
currency: pr.currency,
|
||||
effective_at: pr.effective_at,
|
||||
source: safeJson<SourceReference>(pr.source_json, defaultSource),
|
||||
});
|
||||
pricesByPurchase.set(pr.purchase_item_id, list);
|
||||
}
|
||||
|
||||
for (const p of purchaseRows) {
|
||||
purchaseItemsMap.set(p.id, {
|
||||
schema_version: 2,
|
||||
id: p.id,
|
||||
ingredient_id: p.ingredient_id,
|
||||
name: p.name,
|
||||
...(p.brand ? { brand: p.brand } : {}),
|
||||
...(p.supplier_id ? { supplier_id: p.supplier_id } : {}),
|
||||
...(p.supplier_sku ? { supplier_sku: p.supplier_sku } : {}),
|
||||
status: p.status as PurchaseItem["status"],
|
||||
package: {
|
||||
quantity: p.package_quantity,
|
||||
unit_id: p.package_unit_id,
|
||||
units_per_case: p.units_per_case,
|
||||
usable_yield_factor: p.usable_yield_factor,
|
||||
},
|
||||
prices: pricesByPurchase.get(p.id) ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch source mappings for referenced ingredients
|
||||
const sourceMappingsMap = new Map<string, SourceMapping>();
|
||||
if (ingredientIds.size > 0) {
|
||||
const placeholders = Array.from(ingredientIds).map(() => "?").join(",");
|
||||
const mappingRows = database
|
||||
.prepare(`SELECT * FROM source_mappings WHERE subject_type = 'ingredient' AND subject_id IN (${placeholders}) ORDER BY id`)
|
||||
.all(...Array.from(ingredientIds)) as unknown as SourceMappingRow[];
|
||||
|
||||
for (const m of mappingRows) {
|
||||
sourceMappingsMap.set(m.id, {
|
||||
schema_version: 2,
|
||||
id: m.id,
|
||||
subject: { type: m.subject_type as SourceMapping["subject"]["type"], id: m.subject_id },
|
||||
mapping_type: m.mapping_type as SourceMapping["mapping_type"],
|
||||
status: m.status as SourceMapping["status"],
|
||||
source: safeJson<SourceReference>(m.source_json, defaultSource),
|
||||
...(m.nutrition_json ? { nutrition_per_100g: safeJson(m.nutrition_json, {}) } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const unitsMap = buildDomainUnits(database);
|
||||
const prepActionsMap = buildDomainPrepActions(database);
|
||||
|
||||
return {
|
||||
domainRecipe,
|
||||
recipes: recipeMap,
|
||||
ingredients: ingredientMap,
|
||||
units: unitsMap,
|
||||
purchaseItems: purchaseItemsMap,
|
||||
sourceMappings: sourceMappingsMap,
|
||||
prepActions: prepActionsMap,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type {
|
||||
Ingredient,
|
||||
PrepAction,
|
||||
PurchaseItem,
|
||||
Recipe,
|
||||
SourceMapping,
|
||||
Unit,
|
||||
} from "../types";
|
||||
|
||||
export type UnitRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
dimension: string;
|
||||
system: string;
|
||||
base_unit_id: string | null;
|
||||
factor: number | null;
|
||||
offset: number | null;
|
||||
};
|
||||
|
||||
export type IngredientRow = {
|
||||
id: string;
|
||||
schema_version: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
categories_json: string;
|
||||
tags_json: string;
|
||||
source_json: string;
|
||||
deleted_at: string | null;
|
||||
};
|
||||
|
||||
export type RecipeRow = {
|
||||
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;
|
||||
cover_media_url: string | null;
|
||||
deleted_at: string | null;
|
||||
};
|
||||
|
||||
export type RecipeComponentRow = {
|
||||
recipe_id: string;
|
||||
id: string;
|
||||
position: number;
|
||||
name: string;
|
||||
notes_json: string;
|
||||
};
|
||||
|
||||
export type RecipeItemRow = {
|
||||
recipe_id: string;
|
||||
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;
|
||||
};
|
||||
|
||||
export type ItemPrepActionRow = {
|
||||
recipe_id: string;
|
||||
item_id: string;
|
||||
position: number;
|
||||
action_id: string;
|
||||
yield_factor: number | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export type RecipeStepRow = {
|
||||
recipe_id: string;
|
||||
id: string;
|
||||
position: number;
|
||||
instruction: string;
|
||||
critical_control_point: number;
|
||||
};
|
||||
|
||||
export type RecipeMeasureConversionRow = {
|
||||
recipe_id: string;
|
||||
id: string;
|
||||
from_quantity: number;
|
||||
from_unit_id: string;
|
||||
to_quantity: number;
|
||||
to_unit_id: string;
|
||||
notes: string | null;
|
||||
source_json: string;
|
||||
};
|
||||
|
||||
export type PurchaseItemRow = {
|
||||
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;
|
||||
};
|
||||
|
||||
export type PriceObservationRow = {
|
||||
purchase_item_id: string;
|
||||
effective_at: string;
|
||||
currency: string;
|
||||
amount: number;
|
||||
source_json: string;
|
||||
};
|
||||
|
||||
export type SourceMappingRow = {
|
||||
id: string;
|
||||
subject_type: string;
|
||||
subject_id: string;
|
||||
mapping_type: string;
|
||||
status: string;
|
||||
source_json: string;
|
||||
nutrition_json: string | null;
|
||||
};
|
||||
|
||||
export type RecipeCalculationContext = {
|
||||
domainRecipe: Recipe;
|
||||
recipes: Map<string, Recipe>;
|
||||
ingredients: Map<string, Ingredient>;
|
||||
units: Map<string, Unit>;
|
||||
purchaseItems: Map<string, PurchaseItem>;
|
||||
sourceMappings: Map<string, SourceMapping>;
|
||||
prepActions: Map<string, PrepAction>;
|
||||
};
|
||||
|
||||
export type DirectoryRecipeRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
yield_quantity: number;
|
||||
yield_unit_id: string;
|
||||
item_count: number;
|
||||
placeholder_count: number;
|
||||
};
|
||||
|
||||
export type DirectoryIngredientRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
recipe_count: number;
|
||||
price_count: number;
|
||||
nutrition_count: number;
|
||||
};
|
||||
|
||||
export type DirectoryBookRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
recipe_count: number;
|
||||
};
|
||||
|
||||
export type DirectoryPurchaseRow = {
|
||||
id: string;
|
||||
ingredient_id: string;
|
||||
name: string;
|
||||
supplier_id: string | null;
|
||||
status: string;
|
||||
ingredient_name: string;
|
||||
package_quantity: number;
|
||||
package_unit_id: string;
|
||||
latest_price: number | null;
|
||||
};
|
||||
@@ -167,6 +167,8 @@ export type CalculatorItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
href?: string;
|
||||
attention?: boolean;
|
||||
attentionMessage?: string;
|
||||
amount: Amount;
|
||||
percentage?: number;
|
||||
basisMember?: boolean;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Light.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Book.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Medium.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Bold.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Black.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
--ink: #050841;
|
||||
--ink-dark: #050841;
|
||||
--ink-medium: #202962;
|
||||
--ink-secondary: #3C4679;
|
||||
--muted: #8283a0;
|
||||
--muted-light: #a5a9c1;
|
||||
--muted-subtle: #757677;
|
||||
--paper: #f3f3f3;
|
||||
--card: #ffffff;
|
||||
--card-alt: #fbfbfb;
|
||||
--card-paper: #f1f5fe;
|
||||
--line: #ececec;
|
||||
--line-subtle: #f3f3f3;
|
||||
--line-focus: #dadada;
|
||||
--blue: #3d5df6;
|
||||
--blue-hover: #1236e1;
|
||||
--blue-active: #001992;
|
||||
--blue-light: #647df8;
|
||||
--blue-tint: #f1f5fe;
|
||||
--blue-badge: #dbe4ff;
|
||||
--green: #40b49a;
|
||||
--green-dark: #20ab85;
|
||||
--green-tint: #abddd1;
|
||||
--orange: #f3a642;
|
||||
--red: #f63d48;
|
||||
--red-dark: #bc0020;
|
||||
--red-tint: #fff5f6;
|
||||
--purple: #6234f2;
|
||||
--pink-badge: #ffd2f5;
|
||||
--serif: "CircularCustCapNum", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--sans: "CircularCustCapNum", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-gutter: stable;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
body, button, input, select, textarea {
|
||||
font-family: var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body { margin: 0; min-height: 100vh; }
|
||||
a { color: inherit; text-decoration-thickness: 1px; text-underline-offset: .2em; }
|
||||
.shell { width: min(1160px, calc(100% - 40px)); margin-inline: auto; }
|
||||
|
||||
/* Sticky Detail Utility Header */
|
||||
.detail-utility {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #ececec;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.detail-utility-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.detail-utility-home-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
color: #050841;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.detail-utility-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #050841;
|
||||
color: #ffffff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.detail-utility-divider {
|
||||
color: #a5a9c1;
|
||||
font-size: 13px;
|
||||
}
|
||||
.detail-utility-crumb {
|
||||
color: #050841;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.detail-utility-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.detail-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #fbfbfb;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
width: 360px;
|
||||
}
|
||||
.detail-search-bar svg {
|
||||
color: #8283a0;
|
||||
}
|
||||
.detail-search-bar input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
color: #050841;
|
||||
width: 100%;
|
||||
}
|
||||
.detail-search-bar input::placeholder {
|
||||
color: #8283a0;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
min-height: 64px;
|
||||
padding-inline: max(16px, calc((100vw - 1180px) / 2));
|
||||
background: rgba(255, 255, 255, .96);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand img { width: 28px; height: 28px; }
|
||||
nav { display: flex; gap: 20px; }
|
||||
nav a { font-size: .875rem; text-decoration: none; color: var(--muted); }
|
||||
nav a:hover { color: var(--ink); }
|
||||
|
||||
|
||||
+4146
-365
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Meez Unified Design System Tokens
|
||||
* Measured directly from live authenticated Meez session
|
||||
*/
|
||||
:root {
|
||||
/* Surface Colors */
|
||||
--meez-bg-page: #f3f3f3;
|
||||
--meez-bg-card: #fbfbfb;
|
||||
--meez-bg-card-alt: #f1f5fe;
|
||||
--meez-bg-card-hover: #f1f5fe;
|
||||
--meez-bg-white: #ffffff;
|
||||
--meez-bg-active-pill: #dbe4ff;
|
||||
|
||||
/* Text & Ink Colors */
|
||||
--meez-text-primary: #050841;
|
||||
--meez-text-secondary: #202962;
|
||||
--meez-text-muted: #a5a9c1;
|
||||
--meez-text-subtle: #757677;
|
||||
--meez-text-gray: #8283a0;
|
||||
--meez-text-body: rgba(0, 0, 0, 0.87);
|
||||
--meez-text-danger: #f63d48;
|
||||
|
||||
/* Brand, Type Badge & Accent Colors */
|
||||
--meez-blue: #3d5df6;
|
||||
--meez-blue-hover: #304fdf;
|
||||
--meez-blue-active: #1236e1;
|
||||
--meez-blue-tint: #f1f5fe;
|
||||
--meez-blue-badge: #dbe4ff;
|
||||
--meez-type-recipe: #3c4679;
|
||||
--meez-type-ingredient: #3f908a;
|
||||
--meez-type-book: #f3a642;
|
||||
--meez-type-purchase: #3f908a;
|
||||
--meez-green: #3f908a;
|
||||
--meez-green-dark: #20ab85;
|
||||
--meez-orange: #f3a642;
|
||||
--meez-danger: #f63d48;
|
||||
--meez-danger-hover: #e02834;
|
||||
--meez-danger-bg: #fff1f2;
|
||||
--meez-danger-tint: #fff0e8;
|
||||
--meez-danger-dark: #9a3412;
|
||||
|
||||
/* Border Colors */
|
||||
--meez-border-subtle: #f3f3f3;
|
||||
--meez-border-default: #ececec;
|
||||
--meez-border-strong: #dfe3ec;
|
||||
--meez-border-divider: #eeeef3;
|
||||
|
||||
/* Elevation Shadows (Material UI standard elevations) */
|
||||
--meez-shadow-1: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--meez-shadow-elevation-2: 0 2px 1px -1px rgba(0,0,0,0.2), 0 1px 1px 0 rgba(0,0,0,0.14), 0 1px 3px 0 rgba(0,0,0,0.12);
|
||||
--meez-shadow-elevation-4: 0 2px 4px -1px rgba(0,0,0,0.2), 0 4px 5px 0 rgba(0,0,0,0.14), 0 1px 10px 0 rgba(0,0,0,0.12);
|
||||
--meez-shadow-elevation-8: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12);
|
||||
--meez-shadow-elevation-24: 0 11px 15px -7px rgba(0,0,0,0.2), 0 24px 38px 3px rgba(0,0,0,0.14), 0 9px 46px 8px rgba(0,0,0,0.12);
|
||||
|
||||
/* Border Radii */
|
||||
--meez-radius-xs: 2px;
|
||||
--meez-radius-sm: 4px;
|
||||
--meez-radius-md: 6px;
|
||||
--meez-radius-lg: 8px;
|
||||
--meez-radius-xl: 10px;
|
||||
--meez-radius-pill: 100px;
|
||||
--meez-radius-chip: 999px;
|
||||
--meez-radius-circle: 50%;
|
||||
|
||||
/* Transitions */
|
||||
--meez-transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--meez-transition-normal: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Fonts */
|
||||
--meez-font-sans: "CircularCustCapNum", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
Reference in New Issue
Block a user