clarify data ownership
This commit is contained in:
+2
-4
@@ -33,7 +33,5 @@ Thumbs.db
|
||||
/ui-reference*.png
|
||||
/screenshots/
|
||||
|
||||
# Local application databases and SQLite sidecars
|
||||
/var/*.sqlite
|
||||
/var/*.sqlite-shm
|
||||
/var/*.sqlite-wal
|
||||
# Local application databases, backups, logs, and SQLite sidecars
|
||||
/var/
|
||||
|
||||
@@ -44,17 +44,33 @@ SQLite is the canonical writable store. The database is located at
|
||||
`var/recipe-book.sqlite` and is intentionally excluded from Git. The single
|
||||
baseline in `migrations/001_initial.sql` defines its complete schema.
|
||||
|
||||
All normal recipe, ingredient, nutrition-mapping, and purchasing changes must
|
||||
be written to SQLite through the application or its validated database
|
||||
functions. This rule also applies to automated and AI-assisted edits. Do not
|
||||
edit `culinary/*.yaml` as a way to update a running application, and do not use
|
||||
unrestricted SQL when `saveRecipeStructure()` or another domain save function
|
||||
is available.
|
||||
|
||||
Create a new local database from the portable culinary dataset:
|
||||
|
||||
```bash
|
||||
npm run db:reset
|
||||
```
|
||||
|
||||
This command replaces an existing local database. There is intentionally no
|
||||
legacy upgrade chain. YAML under `culinary/` is retained as portable seed and
|
||||
interchange data; normal edits in the management application write to SQLite.
|
||||
**Warning:** this command deletes and replaces the existing local database with
|
||||
the contents of `culinary/`. Any newer SQLite-only edits will be lost. There is
|
||||
intentionally no legacy upgrade chain. YAML under `culinary/` is retained as
|
||||
portable seed and interchange data; it is not a second writable source of
|
||||
truth.
|
||||
|
||||
Generated projections and future YAML/JSON exports flow outward from SQLite.
|
||||
They are suitable for presentation, backup, interchange, and Git review, but
|
||||
must not be edited independently and treated as authoritative.
|
||||
|
||||
See [Local application](docs/local-application.md) for more detail.
|
||||
For moving development to another machine, including the distinction between a
|
||||
seed rebuild and transferring current SQLite data, see
|
||||
[Agent handoff](docs/agent-handoff.md).
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Agent handoff
|
||||
|
||||
## Repository state
|
||||
|
||||
Development happens on `dev`; `master` is the deployable integration branch.
|
||||
Use Node.js 22 or newer and install dependencies with `npm ci`.
|
||||
|
||||
SQLite is the canonical writable store. YAML in `culinary/` is portable seed and
|
||||
interchange data, not the live editing surface. Application and automated edits
|
||||
should use validated domain functions and transactions rather than unrestricted
|
||||
SQL or direct YAML changes.
|
||||
|
||||
## Start from the committed seed data
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run db:reset
|
||||
npm run dev:app
|
||||
```
|
||||
|
||||
`db:reset` deletes the local database before importing `culinary/`. Do not run it
|
||||
when a newer SQLite database has been transferred from another installation.
|
||||
|
||||
## Transfer the latest application data
|
||||
|
||||
The runtime database and its backups live under `var/`, which is intentionally
|
||||
ignored by Git. A clone therefore contains the application and portable seed,
|
||||
but not necessarily the latest recipe edits.
|
||||
|
||||
To hand off the current live state, create a consistent SQLite backup separately
|
||||
from Git:
|
||||
|
||||
```sh
|
||||
npm run db:backup -- /safe/transfer/recipe-book.sqlite
|
||||
```
|
||||
|
||||
Place the transferred file at `var/recipe-book.sqlite` on the receiving machine.
|
||||
The backup command uses SQLite's online backup API, includes committed WAL data,
|
||||
and refuses to overwrite an existing destination.
|
||||
|
||||
## Validate a change
|
||||
|
||||
```sh
|
||||
scripts/validate-content
|
||||
npm run check:app
|
||||
npm test
|
||||
npm run build:app
|
||||
git diff --check
|
||||
```
|
||||
|
||||
The application supports a read-only deployment with
|
||||
`FORMULATION_READ_ONLY=true`. Ingredient bulk parsing additionally accepts
|
||||
`FORMULATION_OLLAMA_URL` and `FORMULATION_INGREDIENT_PARSER_MODEL`; USDA imports
|
||||
read `USDA_FDC_API_KEY` from the environment.
|
||||
@@ -13,10 +13,11 @@ current canonical state. Names used for search or display belong in ingredient
|
||||
|
||||
## Canonical and derived boundaries
|
||||
|
||||
Canonical records live under `culinary/`. They contain authored or observed
|
||||
Canonical writable records live in SQLite. They include authored or observed
|
||||
facts: recipes, ingredients, measurements, provenance, suppliers, packages,
|
||||
and price observations. Projections may later be generated for another
|
||||
application or database, but they are never canonical.
|
||||
and price observations. YAML under `culinary/` is portable seed/interchange
|
||||
data, while generated site projections and exports are downstream products.
|
||||
Neither is an independently writable source of truth.
|
||||
|
||||
Derived recipe records contain reproducible nutrition, allergen rollups, and
|
||||
costs. They identify the recipe, calculation version, calculation time, and an
|
||||
@@ -129,7 +130,14 @@ truth. Recipes without authored instructions contain one explicit TODO step.
|
||||
Formula-only conversions use a nominal 100 g basis and a
|
||||
theoretical yield until those values are replaced by observed production data.
|
||||
|
||||
Astro reads a generated, read-only SQLite projection and produces static recipe
|
||||
pages. Interactive calculators are small Preact islands supplied with resolved,
|
||||
typed recipe data. Astro and Preact remain presentation consumers; culinary
|
||||
calculations and editing originate in the database and shared calculation tools.
|
||||
Application and agent changes must use validated domain save functions and
|
||||
SQLite transactions. Direct SQL is reserved for schema-aware maintenance where
|
||||
no domain operation exists. Reset/import commands flow from YAML into SQLite and
|
||||
therefore overwrite the current store; export commands flow from SQLite into a
|
||||
portable representation.
|
||||
|
||||
Astro reads SQLite through the application data layer. Generated projections
|
||||
support read-only presentation, and interactive calculators are Preact islands
|
||||
supplied with resolved, typed recipe data. Astro, Preact, and projections remain
|
||||
presentation consumers; culinary calculations and editing originate in SQLite
|
||||
and shared domain tools.
|
||||
|
||||
@@ -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,7 @@ 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.
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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,{})})),
|
||||
|
||||
@@ -4,6 +4,7 @@ import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import EntityDirectory from "../../../components/EntityDirectory";
|
||||
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 });
|
||||
@@ -41,12 +42,12 @@ const tabs=[
|
||||
];
|
||||
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}/`})),
|
||||
...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"ingredient",label:"Ingredient",icon:"●",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)=>({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`})),
|
||||
...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: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 ingredientRows=filteredIngredients.map(item=>({id:item.id,name:titleCase(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:"$"}));
|
||||
|
||||
@@ -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!;
|
||||
@@ -129,10 +129,10 @@ const prepDisplay = prep.map((row) => {
|
||||
});
|
||||
database.close();
|
||||
---
|
||||
<BaseLayout title={ingredient.name} immersive>
|
||||
<BaseLayout title={titleCase(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>
|
||||
<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>{!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">
|
||||
|
||||
@@ -170,7 +170,7 @@ const saved = Astro.url.searchParams.get("saved") === "1";
|
||||
<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} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnit={unitMap.get(domainRecipe.yield.amount.unit_id)?.symbol??domainRecipe.yield.amount.unit_id}/></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 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} showPercentControls={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} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnit={unitMap.get(domainRecipe.yield.amount.unit_id)?.symbol??domainRecipe.yield.amount.unit_id}/></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>
|
||||
{!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>}
|
||||
{editing&&<script is:inline>
|
||||
|
||||
@@ -17,6 +17,7 @@ type Props = {
|
||||
cost: CostResult;
|
||||
servings?: number;
|
||||
showDerived?: boolean;
|
||||
showPercentControls?: boolean;
|
||||
yieldConversions?: CalculatorItem["measureConversions"];
|
||||
};
|
||||
|
||||
@@ -46,7 +47,7 @@ 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 [percentMode,setPercentMode]=useState<"standard"|"bakers">("standard");
|
||||
@@ -97,7 +98,7 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
|
||||
<span>Finished yield</span>
|
||||
<span class="quantity-control recipe-scale-control"><input type="number" min="0" step="any" value={roundForDisplay(scaledYield)} onInput={(event) => changeYield(Number(event.currentTarget.value))}/><select aria-label="Yield unit" value={yieldDisplayUnitId} onChange={(event) => setYieldDisplayUnitId(event.currentTarget.value)}>{yieldUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}</select></span>
|
||||
</label>
|
||||
<div class="calculator-percent-controls">{calculatePercent&&<span class="percent-mode"><button class={percentMode==="standard"?"active":""} onClick={()=>setPercentMode("standard")}>Standard %</button><button class={percentMode==="bakers"?"active":""} onClick={()=>setPercentMode("bakers")}>Baker's %</button></span>}<label class="calculate-toggle"><span>Calculate %</span><input type="checkbox" checked={calculatePercent} onChange={(event)=>setCalculatePercent(event.currentTarget.checked)}/><i></i></label></div>
|
||||
<div class="calculator-percent-controls">{showPercentControls&&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>}{showPercentControls&&<label class="calculate-toggle"><span>Calculate %</span><input type="checkbox" checked={calculatePercent} onChange={(event)=>setCalculatePercent(event.currentTarget.checked)}/><i></i></label>}</div>
|
||||
</div>
|
||||
<p class="scale-relationship">1× produces {number(yieldQuantity)} {units[yieldUnitId]?.symbol??yieldUnitId} finished yield. Changing the multiplier, finished yield, or any ingredient amount scales the entire recipe.</p>
|
||||
|
||||
|
||||
+12
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { number, roundForDisplay } from "./format";
|
||||
import { number, roundForDisplay, titleCase } from "./format";
|
||||
|
||||
describe("display number formatting", () => {
|
||||
it("uses two decimal places for ordinary values", () => {
|
||||
@@ -17,3 +17,14 @@ describe("display number formatting", () => {
|
||||
expect(number(0)).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("title case formatting", () => {
|
||||
it("capitalizes the first letter of each word without lowercasing source data", () => {
|
||||
expect(titleCase("baking powder, double-acting")).toBe("Baking Powder, Double-acting");
|
||||
expect(titleCase("USDA choice beef")).toBe("USDA Choice Beef");
|
||||
});
|
||||
|
||||
it("preserves punctuation and whitespace-separated numeric tokens", () => {
|
||||
expect(titleCase("2% milk")).toBe("2% Milk");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,17 @@ export function number(value: number) {
|
||||
return new Intl.NumberFormat("en-US", { maximumFractionDigits: displayDigits(value) }).format(value);
|
||||
}
|
||||
|
||||
export function titleCase(input: string) {
|
||||
return input
|
||||
.split(/\s+/)
|
||||
.map((word) => {
|
||||
const index = word.search(/\p{L}/u);
|
||||
if (index === -1) return word;
|
||||
return word.slice(0, index) + word[index].toLocaleUpperCase() + word.slice(index + 1);
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function amount(value: Amount, units: Map<string, Unit>) {
|
||||
if (value.display) return value.display;
|
||||
const unit = units.get(value.unit_id);
|
||||
|
||||
Reference in New Issue
Block a user