Compare commits

...
5 Commits
Author SHA1 Message Date
nicholas 21651e1ce8 feat(ui): meez visual alignment and unified responsive layouts across desktop and mobile
- Resolve ingredient table overflow and 2-column layout on laptop screens (1280px-1440px)
- Align recipe, ingredient, and directory pages with meez visual design specifications
- Implement segmented icon navigation tabs across recipe and ingredient detail views
- Standardize top utility bar and entity detail header alignment across all viewports
- Fix mobile home page filter popup z-index and viewport overflow clipping
- Clean up legacy prototype media queries and consolidate responsive CSS system
2026-08-17 00:48:35 -05:00
nicholas 272642f372 feat(home): meez-style home page skin
- Header app bar: full-bleed #FBFBFB h80 with #F3F3F3 border, 1120px content column
- Search: borderless-underline MUI textbox (16px, focus #3D5DF6 underline), placeholder 'Search '
- Nav chips: h40 r999 label 16px/400 rgba(0,0,0,.87), active #DBE4FF, inline count, no leading icon
- Filter + item-type chips: h44 r20 white
- New button: 16px/500 blue pill
- Directory: head row 57px #FBFBFB with Type/Name labels + select-all, rows 57px 36|112|1fr|36,
  flat 24x24 type glyphs, blue 16/300 name links, kebab 24x24 r50 revealed on row hover,
  no row hover bg, meez-menu-in entry animation
- Mobile <=760px: kebab always visible, row grid fits 390px, head row hidden
2026-08-14 21:42:29 -05:00
nicholas f0e82ff19f style: meez cosmetic alignment on formulation UI; add site spec and harness allowScripts 2026-08-14 21:16:48 -05:00
nicholas 551bdc4104 clarify data ownership 2026-08-14 18:24:33 -05:00
nicholas deb2c15ab4 feat: expand recipe editing, ingredient import, and costing 2026-08-14 13:38:46 -05:00
35 changed files with 6126 additions and 809 deletions
+3 -4
View File
@@ -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/
+25 -3
View File
@@ -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
+54
View File
@@ -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.
+15 -7
View File
@@ -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.
+50 -2
View File
@@ -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.
+207
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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.
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { backup, DatabaseSync } from "node:sqlite";
const source = path.resolve(process.cwd(), "var", "recipe-book.sqlite");
const requestedTarget = process.argv[2];
if (!requestedTarget) {
throw new Error("Usage: npm run db:backup -- /path/to/recipe-book.sqlite");
}
if (!fs.existsSync(source)) {
throw new Error(`Database not found: ${source}`);
}
const target = path.resolve(requestedTarget);
if (target === source) {
throw new Error("Backup target must differ from the live database.");
}
if (fs.existsSync(target)) {
throw new Error(`Refusing to overwrite existing backup: ${target}`);
}
fs.mkdirSync(path.dirname(target), { recursive: true });
const database = new DatabaseSync(source, { readOnly: true });
try {
await backup(database, target);
} finally {
database.close();
}
console.log(`Backed up ${source} to ${target}`);
+15
View File
@@ -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);
}
+83
View File
@@ -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 -1
View File
@@ -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 });
}
};
+51 -33
View File
@@ -2,8 +2,21 @@
export const prerender = false;
import BaseLayout from "../../../layouts/BaseLayout.astro";
import EntityDirectory from "../../../components/EntityDirectory";
const TYPE_ICONS:Record<"recipe"|"ingredient"|"book"|"purchase",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<"recipe"|"ingredient"|"book"|"purchase",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
};
import { openDatabase } from "../../../lib/database";
import { readOnlyMode } from "../../../lib/runtime";
import { titleCase } from "../../../lib/format";
const database = openDatabase();
if (!database) return new Response("Database unavailable", { status:503 });
@@ -22,8 +35,8 @@ const purchases=database.prepare(`SELECT p.id,p.ingredient_id,p.name,p.supplier_
FROM purchase_items p JOIN ingredients i ON i.id=p.ingredient_id ORDER BY p.name`).all() as any[];
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");
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 +46,66 @@ 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"}>=[
{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"},
];
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>☷ &nbsp; 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>☷ &nbsp; 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>☷ &nbsp; 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={`/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>
+344 -22
View File
@@ -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>
+293 -37
View File
@@ -1,7 +1,7 @@
---
export const prerender = false;
import BaseLayout from "../../../../layouts/BaseLayout.astro";
import { databaseProjection, duplicateRecipe, editableRecipe, openDatabase, refreshSiteProjection, saveRecipeMetadata } from "../../../../lib/database";
import { databaseProjection, editableRecipe, openDatabase, refreshSiteProjection, saveRecipeMetadata } from "../../../../lib/database";
import { recipeStructure } from "../../../../lib/database";
import RecipeStructureEditor from "../../../../components/RecipeStructureEditor";
import RecipeCalculator, { LiveCostValues, LiveNutritionValues } from "../../../../components/RecipeCalculator";
@@ -21,22 +21,20 @@ 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`;
database.close();
return Astro.redirect(redirectTo, 303);
try {
return Astro.redirect(`/app/recipes/${duplicateRecipe(database, id)}/?edit=1`, 303);
} finally {
database.close();
}
}
if(form.get("intent")==="auto_yield"){
const current=editableRecipe(database,id);if(!current)throw new Error("Recipe not found.");
@@ -108,13 +106,15 @@ if (!recipe) { database.close(); return new Response("Recipe not found", { statu
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 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 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 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]));
@@ -122,7 +122,8 @@ const ingredientMap = new Map(projection.ingredients.map((entry) => [entry.id, e
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 reviewedNutritionMappingIds=new Set(projection.sourceMappings.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=>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];}})));
@@ -143,42 +144,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>
+36 -2
View File
@@ -20,9 +20,43 @@ const resolvedHref=sectionHref??inferred.href;
<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();
});
</script>
+51 -13
View File
@@ -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}&nbsp;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>;
+60 -21
View File
@@ -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,52 @@ 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 class="scale-suffix">x</span>
</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 +131,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 +143,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 +193,25 @@ 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 href=line.kind==="ingredient"?`/app/ingredients/${line.subjectId}/${editable?"?edit=1#costs":"#costs"}`:`/app/recipes/${line.subjectId}/#costing`;
const body=<>
<span class={`cost-subject-icon ${line.kind}`}>{line.kind==="recipe"?"R":""}</span>
<a href={href}>{line.name}</a>
{line.completeness<1&&<span class="cost-attention" title="Cost information is incomplete" aria-label="Cost information is incomplete"><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 class="cost-line-value">{line.cost!=null?money.format(line.cost*factor):line.kind==="ingredient"&&editable?<a href={href}>Add cost&nbsp; ✎</a>:""}</span>
</>;
if(line.purchase||line.children?.length)return <details class="cost-ledger-line" open={expanded}><summary>{body}</summary><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>;
return <div class="cost-ledger-line flat">{body}</div>;
}
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 ingredients 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>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>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
+13
View File
@@ -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
View File
@@ -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,
};
}
+11 -1
View File
@@ -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 (?, ?, ?, ?, ?)");
+12 -1
View File
@@ -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");
});
});
+11
View File
@@ -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);
+27
View File
@@ -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");
});
});
+186
View File
@@ -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);
}
+2
View File
@@ -167,6 +167,8 @@ export type CalculatorItem = {
id: string;
label: string;
href?: string;
attention?: boolean;
attentionMessage?: string;
amount: Amount;
percentage?: number;
basisMember?: boolean;
+3653 -358
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -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;
}