From f3fd13fcf4f9affd4cd98044f1de68e80cebaf28 Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Tue, 3 Nov 2026 18:57:45 +0100 Subject: [PATCH] fix(library): cards fall back to computed OG, FG, ABV, IBU and color (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recipe cards only showed the figures stored in the file: OG, FG and ABV read "—" when the file lacked them, IBU showed "—" with the estimate as a small subtitle, and the color swatch only appeared with a stored color_estimate. The recipe screen already showed Werb's estimates in that case, so a recipe like the Punk IPA sample (OG/FG only) looked incomplete in the library. - data/recipe-targets.ts: claimed + computed OG, FG, ABV and SRM, same rules as the recipe screen (OG from the grain bill, FG from attenuation, ABV from the shown OG/FG). An empty grain bill gives no estimate rather than 1.000. - RecipeCard: shows the file value, else the estimate; IBU keeps the "≈estimate" subtitle when the file states one. Exported for tests. Tests: recipe-targets unit tests, RecipeCard component tests, e2e on the Punk IPA sample card. --- CHANGELOG.md | 5 ++ apps/desktop/src/data/recipe-targets.ts | 80 ++++++++++++++++++++++++ apps/desktop/src/screens/Library.tsx | 54 +++++++++------- apps/desktop/test/e2e/smoke.spec.ts | 13 ++++ apps/desktop/test/library-card.test.tsx | 53 ++++++++++++++++ apps/desktop/test/recipe-targets.test.ts | 62 ++++++++++++++++++ 6 files changed, 244 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/src/data/recipe-targets.ts create mode 100644 apps/desktop/test/library-card.test.tsx create mode 100644 apps/desktop/test/recipe-targets.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dadc4d2..fa91c7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **Library cards show OG, FG, ABV, IBU and color for every recipe** + ([#49](https://github.com/werb-dev/werb/issues/49)). A card used to show "—" + for any figure the recipe file didn't state (e.g. ABV on an imported BeerXML + without an estimate) and no color swatch. It now falls back to Werb's + estimate from the ingredients, as the recipe screen already did. - **Default water on the web app.** New recipes now start from your water on the web too; they used to start from zero because the saved default loaded after the recipe had already picked its water. A recipe you haven't touched diff --git a/apps/desktop/src/data/recipe-targets.ts b/apps/desktop/src/data/recipe-targets.ts new file mode 100644 index 0000000..0b76dc8 --- /dev/null +++ b/apps/desktop/src/data/recipe-targets.ts @@ -0,0 +1,80 @@ +import { + recipeApparentAttenuationPct, + recipeToColorInput, + recipeToGravityInput, + toSrm, + type BeerJsonRecipe, +} from "@werb/adapters"; +import { computeAbv, computeColor, computeFg, computeGravity } from "@werb/calc"; +import type { UnitPreferences } from "./units-format.ts"; + +/** A recipe figure as the file states it and as Werb computes it. */ +export interface TargetValue { + claimed: number | null; + /** null when the ingredients can't produce an estimate. */ + computed: number | null; +} + +export interface RecipeTargets { + og: TargetValue; + fg: TargetValue; + abv: TargetValue; + srm: TargetValue; +} + +/** The value to show: the file's, else Werb's estimate. */ +export function shownValue(v: TargetValue): number | null { + return v.claimed ?? v.computed; +} + +function positive(n: number | null): number | null { + return n !== null && n > 0 ? n : null; +} + +function attempt(fn: () => number): number | null { + try { + const n = fn(); + return Number.isFinite(n) ? n : null; + } catch { + return null; + } +} + +/** + * OG, FG, ABV and color for a recipe, claimed and computed. Same rules as + * the recipe screen: OG from the grain bill, FG from the yeast attenuation, + * ABV from the shown OG and FG. + */ +export function recipeTargets( + recipe: BeerJsonRecipe, + colorMethod: UnitPreferences["color_method"], +): RecipeTargets { + // An empty grain bill computes to OG 1.000 and color 0: no estimate. + const ogRaw = attempt(() => computeGravity(recipeToGravityInput(recipe)).og); + const ogComputed = ogRaw !== null && ogRaw > 1 ? ogRaw : null; + const fgComputed = + ogComputed !== null + ? attempt(() => computeFg(ogComputed, recipeApparentAttenuationPct(recipe))) + : null; + const og: TargetValue = { claimed: recipe.original_gravity?.value ?? null, computed: ogComputed }; + const fg: TargetValue = { claimed: recipe.final_gravity?.value ?? null, computed: fgComputed }; + const ogShown = shownValue(og); + const fgShown = shownValue(fg); + return { + og, + fg, + abv: { + claimed: recipe.alcohol_by_volume?.value ?? null, + computed: + ogShown !== null && fgShown !== null + ? attempt(() => computeAbv(ogShown, fgShown)) + : null, + }, + srm: { + claimed: recipe.color_estimate ? toSrm(recipe.color_estimate) : null, + computed: positive( + attempt(() => computeColor({ ...recipeToColorInput(recipe), method: colorMethod }).srm), + ), + }, + }; +} diff --git a/apps/desktop/src/screens/Library.tsx b/apps/desktop/src/screens/Library.tsx index 99b7e91..3d3ffc9 100644 --- a/apps/desktop/src/screens/Library.tsx +++ b/apps/desktop/src/screens/Library.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { recipeToIbuInput, recipeToWaterInput, - toSrm, srmToHex, type BeerJsonRecipe, } from "@werb/adapters"; @@ -20,8 +19,10 @@ import { formatColor, formatLiters, formatSpecificGravity, + formatSrm, formatVolume, } from "../data/units-format.ts"; +import { recipeTargets, shownValue } from "../data/recipe-targets.ts"; interface LibraryScreenProps { recipes: StoredRecipe[]; @@ -193,7 +194,7 @@ export function LibraryScreen({ ); } -function RecipeCard({ +export function RecipeCard({ recipe, activeProfile, onSelect, @@ -208,10 +209,18 @@ function RecipeCard({ }) { const prefs = useUnits(); const t = useT(); - const beerColor = recipe.color_estimate ? srmToHex(toSrm(recipe.color_estimate)) : null; + // File values when present, else Werb's estimates, as on the recipe screen. + const targets = recipeTargets(recipe, prefs.color_method); + const og = shownValue(targets.og); + const fg = shownValue(targets.fg); + const abv = shownValue(targets.abv); + const srm = shownValue(targets.srm); + const beerColor = srm !== null ? srmToHex(srm) : null; const colorLabel = recipe.color_estimate ? formatColor(recipe.color_estimate, prefs).display - : null; + : srm !== null + ? formatSrm(srm, prefs).display + : null; const computedIbu = (() => { try { return computeIbu({ @@ -273,6 +282,7 @@ function RecipeCard({ {beerColor && ( +
{label}
{value} diff --git a/apps/desktop/test/e2e/smoke.spec.ts b/apps/desktop/test/e2e/smoke.spec.ts index 85e35f0..0159685 100644 --- a/apps/desktop/test/e2e/smoke.spec.ts +++ b/apps/desktop/test/e2e/smoke.spec.ts @@ -342,6 +342,19 @@ test.describe("Recipe editor — hops", () => { }); }); +test.describe("Library cards", () => { + test("fill ABV, IBU and color from estimates when the file lacks them (#49)", async ({ page }) => { + const app = new App(page); + await app.go("Library"); + await app.library.importSamples(); + // The Punk IPA sample has OG and FG but no ABV, IBU or color. + const card = page.locator("li", { has: page.locator("h2", { hasText: /punk ipa/i }) }); + await expect(card.getByTestId("card-stat-abv").locator("dd")).toHaveText(/^\d+\.\d%$/); + await expect(card.getByTestId("card-stat-ibu").locator("dd")).toHaveText(/^\d+$/); + await expect(card.getByTestId("card-color")).toBeVisible(); + }); +}); + test.describe("Discoverability", () => { test("Library onboarding mentions local storage + GitHub sync", async ({ page }) => { const app = new App(page); diff --git a/apps/desktop/test/library-card.test.tsx b/apps/desktop/test/library-card.test.tsx new file mode 100644 index 0000000..208e02b --- /dev/null +++ b/apps/desktop/test/library-card.test.tsx @@ -0,0 +1,53 @@ +import { afterEach, describe, it, expect } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import type { BeerJsonFile, BeerJsonRecipe } from "@werb/adapters"; +import { RecipeCard } from "../src/screens/Library.tsx"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const load = (file: string): BeerJsonRecipe => + (JSON.parse(readFileSync(join(__dirname, "../../../examples", file), "utf8")) as BeerJsonFile) + .beerjson.recipes![0]!; + +const noop = () => {}; +const card = (recipe: BeerJsonRecipe) => + render(); +const stat = (id: string) => screen.getByTestId(id).querySelector("dd")!.textContent ?? ""; + +describe("RecipeCard", () => { + afterEach(cleanup); + + it("fills ABV, IBU and color from Werb's estimates when the file lacks them (#49)", () => { + card(load("brewdog-punk-ipa-2007.beerjson")); + expect(stat("card-stat-og")).toBe("1.056"); + expect(stat("card-stat-fg")).toBe("1.010"); + expect(stat("card-stat-abv")).toMatch(/^\d+\.\d%$/); + expect(stat("card-stat-ibu")).toMatch(/^\d+$/); + expect(screen.getByTestId("card-color")).toBeTruthy(); + }); + + it("shows the file's values, with Werb's IBU estimate alongside", () => { + const recipe = load("cascade-pale-ale.beerjson"); + card(recipe); + expect(stat("card-stat-abv")).toBe(`${recipe.alcohol_by_volume!.value.toFixed(1)}%`); + expect(stat("card-stat-ibu")).toMatch(new RegExp(`^${recipe.ibu_estimate!.ibu!.value}≈\\d+$`)); + }); + + it("shows dashes when there is nothing to estimate from", () => { + const empty = { + ...load("cascade-pale-ale.beerjson"), + original_gravity: undefined, + final_gravity: undefined, + alcohol_by_volume: undefined, + ibu_estimate: undefined, + color_estimate: undefined, + ingredients: { fermentable_additions: [] }, + } as unknown as BeerJsonRecipe; + card(empty); + expect(stat("card-stat-og")).toBe("—"); + expect(stat("card-stat-abv")).toBe("—"); + expect(screen.queryByTestId("card-color")).toBeNull(); + }); +}); diff --git a/apps/desktop/test/recipe-targets.test.ts b/apps/desktop/test/recipe-targets.test.ts new file mode 100644 index 0000000..59add60 --- /dev/null +++ b/apps/desktop/test/recipe-targets.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import type { BeerJsonFile, BeerJsonRecipe } from "@werb/adapters"; +import { recipeTargets, shownValue } from "../src/data/recipe-targets.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const load = (file: string): BeerJsonRecipe => + (JSON.parse(readFileSync(join(__dirname, "../../../examples", file), "utf8")) as BeerJsonFile) + .beerjson.recipes![0]!; + +// Punk IPA ships OG and FG but no ABV, IBU or color. +const punk = load("brewdog-punk-ipa-2007.beerjson"); +const cascade = load("cascade-pale-ale.beerjson"); + +describe("recipeTargets", () => { + it("estimates ABV from the file's OG and FG when the file has none", () => { + const t = recipeTargets(punk, "Morey"); + expect(t.abv.claimed).toBeNull(); + // (1.056 − 1.010) × 131.25 ≈ 6.0 % + expect(shownValue(t.abv)).toBeCloseTo(6.0, 1); + }); + + it("estimates the color from the grain bill when the file has none", () => { + const t = recipeTargets(punk, "Morey"); + expect(t.srm.claimed).toBeNull(); + expect(shownValue(t.srm)).toBeGreaterThan(0); + }); + + it("shows the file's values first", () => { + const t = recipeTargets(cascade, "Morey"); + expect(shownValue(t.og)).toBe(cascade.original_gravity!.value); + expect(shownValue(t.fg)).toBe(cascade.final_gravity!.value); + expect(shownValue(t.abv)).toBe(cascade.alcohol_by_volume!.value); + expect(t.og.computed).toBeGreaterThan(1); + }); + + it("estimates OG and FG from the ingredients when the file has none", () => { + const { original_gravity: _og, final_gravity: _fg, alcohol_by_volume: _abv, ...bare } = cascade; + const t = recipeTargets(bare, "Morey"); + expect(shownValue(t.og)).toBeGreaterThan(1.03); + expect(shownValue(t.fg)!).toBeLessThan(shownValue(t.og)!); + expect(shownValue(t.abv)).toBeGreaterThan(3); + }); + + it("no estimate without fermentables", () => { + const empty: BeerJsonRecipe = { + ...cascade, + original_gravity: undefined, + final_gravity: undefined, + alcohol_by_volume: undefined, + color_estimate: undefined, + ingredients: { ...cascade.ingredients, fermentable_additions: [] }, + } as unknown as BeerJsonRecipe; + const t = recipeTargets(empty, "Morey"); + expect(shownValue(t.og)).toBeNull(); + expect(shownValue(t.fg)).toBeNull(); + expect(shownValue(t.abv)).toBeNull(); + expect(shownValue(t.srm)).toBeNull(); + }); +});