clarify data ownership

This commit is contained in:
2026-08-14 18:24:33 -05:00
parent deb2c15ab4
commit 551bdc4104
14 changed files with 186 additions and 27 deletions
+2 -4
View File
@@ -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/
+19 -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
+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.
+25 -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,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.
+1
View File
@@ -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"
},
+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}`);
+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,{})})),
+4 -3
View File
@@ -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">
+1 -1
View File
@@ -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>
+3 -2
View File
@@ -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
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);