Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions apps/desktop/src/data/recipe-targets.ts
Original file line number Diff line number Diff line change
@@ -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),
),
},
};
}
54 changes: 31 additions & 23 deletions apps/desktop/src/screens/Library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useState } from "react";
import {
recipeToIbuInput,
recipeToWaterInput,
toSrm,
srmToHex,
type BeerJsonRecipe,
} from "@werb/adapters";
Expand All @@ -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[];
Expand Down Expand Up @@ -193,7 +194,7 @@ export function LibraryScreen({
);
}

function RecipeCard({
export function RecipeCard({
recipe,
activeProfile,
onSelect,
Expand All @@ -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({
Expand Down Expand Up @@ -273,6 +282,7 @@ function RecipeCard({
{beerColor && (
<span
aria-hidden
data-testid="card-color"
className="block w-10 h-10 rounded-pill border border-border-strong shrink-0"
style={{ backgroundColor: beerColor }}
title={colorLabel ?? undefined}
Expand All @@ -290,30 +300,29 @@ function RecipeCard({
/>
<Stat
label={t("library.card.stat.og")}
value={
recipe.original_gravity
? formatSpecificGravity(recipe.original_gravity.value, prefs).display
: "—"
}
value={og !== null ? formatSpecificGravity(og, prefs).display : "—"}
testId="card-stat-og"
/>
<Stat
label={t("library.card.stat.abv")}
value={
recipe.alcohol_by_volume
? `${recipe.alcohol_by_volume.value.toFixed(1)}%`
: "—"
}
value={abv !== null ? `${abv.toFixed(1)}%` : "—"}
testId="card-stat-abv"
/>
<Stat
label={t("library.card.stat.ibu")}
value={claimedIbu !== null ? claimedIbu.toString() : "—"}
value={
claimedIbu !== null
? claimedIbu.toString()
: computedIbu !== null
? computedIbu.toFixed(0)
: "—"
}
sub={
computedIbu !== null && claimedIbu !== null
? `≈${computedIbu.toFixed(0)}`
: computedIbu !== null
? computedIbu.toFixed(0)
: undefined
}
testId="card-stat-ibu"
warn={
claimedIbu !== null &&
computedIbu !== null &&
Expand All @@ -322,11 +331,8 @@ function RecipeCard({
/>
<Stat
label={t("library.card.stat.fg")}
value={
recipe.final_gravity
? formatSpecificGravity(recipe.final_gravity.value, prefs).display
: "—"
}
value={fg !== null ? formatSpecificGravity(fg, prefs).display : "—"}
testId="card-stat-fg"
/>
<Stat
label={t("library.card.stat.water")}
Expand All @@ -351,14 +357,16 @@ function Stat({
value,
sub,
warn,
testId,
}: {
label: string;
value: string;
sub?: string | undefined;
warn?: boolean | undefined;
testId?: string | undefined;
}) {
return (
<div>
<div data-testid={testId}>
<dt className="text-caption text-text-muted">{label}</dt>
<dd className={`text-body-sm mt-0.5 ${warn ? "text-warning" : "text-text"}`}>
{value}
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/test/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
53 changes: 53 additions & 0 deletions apps/desktop/test/library-card.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<RecipeCard recipe={recipe} onSelect={noop} onDuplicate={noop} onDelete={noop} />);
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();
});
});
62 changes: 62 additions & 0 deletions apps/desktop/test/recipe-targets.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading