From 005fd1400accd5586ad04c37c4cfbd4298d20047 Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Tue, 3 Nov 2026 18:11:18 +0100 Subject: [PATCH] feat(water): "My water" in Settings, picked like a preset in recipes The brewer's own water lived inside each recipe's water chemistry, behind a "Save as default" button next to the moneaudebrassage.fr loader: hard to find, and editing it meant opening a recipe. On the web build the saved default never reached new recipes: settings load asynchronously, and the recipe form had already captured an all-zero water as its initial state. - Settings: new "My water" section (six ions + moneaudebrassage.fr loader, desktop only as before). Same storage key, so an existing default carries over. - Recipe water chemistry: the source selector lists "My water" first ("not set yet" and disabled until configured), "This recipe's water" when the BeerJSON carries one, then the city presets. Ions stay editable. "Save as default" and the loaders are replaced by a hint with a link to Settings. - A recipe whose water was never touched stores `source: null` and follows My water, including later changes; picking "My water" goes back to that. - Shared pieces moved out of WaterChemistrySection: data/home-water.ts, components/water/IonFields.tsx and MabLoader.tsx. Tests: sourceSelection unit tests; e2e sets My water in Settings, checks a recipe picks it up, switches to a preset and back, and follows the link. --- CHANGELOG.md | 10 + SPEC.md | 1 + apps/desktop/src/App.tsx | 1 + .../src/components/water/IonFields.tsx | 59 +++ .../src/components/water/MabLoader.tsx | 147 +++++++ apps/desktop/src/data/home-water.ts | 57 +++ apps/desktop/src/data/i18n.ts | 17 +- apps/desktop/src/screens/Recipe.tsx | 6 +- .../screens/Recipe/WaterChemistrySection.tsx | 361 ++++-------------- apps/desktop/src/screens/Settings.tsx | 5 + .../src/screens/Settings/HomeWaterCard.tsx | 25 ++ apps/desktop/test/e2e/smoke.spec.ts | 24 ++ apps/desktop/test/water-source.test.ts | 25 +- 13 files changed, 451 insertions(+), 287 deletions(-) create mode 100644 apps/desktop/src/components/water/IonFields.tsx create mode 100644 apps/desktop/src/components/water/MabLoader.tsx create mode 100644 apps/desktop/src/data/home-water.ts create mode 100644 apps/desktop/src/screens/Settings/HomeWaterCard.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 231990c..dadc4d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,19 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). a different one (more than 0.5 L or 5 % apart), the recipe screen and brew mode say so, with the profile's boil-off. Werb's volumes still follow your profile. +- **"My water" in Settings.** Set the water you brew with once: type the six + ions or load them from moneaudebrassage.fr (desktop app). New recipes use + it, and each recipe's water chemistry lists it as *My water* next to the + city profiles, plus *This recipe's water* when the file carries one. The + recipe's *Save as default* and loader buttons are replaced by a link to + Settings. ### Fixed +- **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 + follows *My water* when you change it in Settings. - **Sparge temperature from the recipe.** A BeerJSON `sparge` mash step sets the brew mode sparge temperature instead of showing up as an extra mash rest. BeerXML and JolieBulle imports read `SPARGE_TEMP` into that step, and BeerXML diff --git a/SPEC.md b/SPEC.md index 6f6cb0b..a9b7301 100644 --- a/SPEC.md +++ b/SPEC.md @@ -179,6 +179,7 @@ A single command (`pnpm gen:types`) regenerates every type. CI checks they're up - EN + FR, light + Cassis-dark themes - iPad PWA path validated against iOS 15 Safari - Source-water profile presets (Pilsen, Munich, Dortmund, Vienna, Burton, London, Edinburgh, Dublin, RO) bundled in the catalog and filled in from the Recipe water-chemistry form +- "My water" in Settings (typed or loaded from moneaudebrassage.fr): new recipes follow it, and each recipe's water chemistry offers it next to the presets - BIAB equipment mode: all water in the kettle at once, no sparge step - Alternative IBU (Rager) and color (Daniels) methods, switchable in Settings - Yeast starter sizing using the Braukaiser growth model with stir-plate / shake / still aeration diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 19f656d..9ddca23 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -98,6 +98,7 @@ export function App() { onBack={goLibrary} onStartBrewing={() => goBrew(state.recipeId)} onEdit={() => goEditRecipe(state.recipeId)} + onOpenSettings={goSettings} onApplyScaled={ profile ? (scaled) => recipesApi.update(state.recipeId, scaled) diff --git a/apps/desktop/src/components/water/IonFields.tsx b/apps/desktop/src/components/water/IonFields.tsx new file mode 100644 index 0000000..4db249d --- /dev/null +++ b/apps/desktop/src/components/water/IonFields.tsx @@ -0,0 +1,59 @@ +import type { IonProfile } from "../../data/home-water.ts"; +import { useNumericText } from "../editor/Fields.tsx"; + +/** The six brewing ions as editable ppm fields. */ +export function IonFields({ + ions, + onChange, + testIdPrefix, +}: { + ions: IonProfile; + onChange: (next: IonProfile) => void; + /** Gives each input `data-testid="-"`. */ + testIdPrefix?: string; +}) { + const field = (label: string, key: keyof IonProfile) => ( + onChange({ ...ions, [key]: v })} + {...(testIdPrefix && { testId: `${testIdPrefix}-${key}` })} + /> + ); + return ( +
+ {field("Ca²⁺", "ca_ppm")} + {field("Mg²⁺", "mg_ppm")} + {field("Na⁺", "na_ppm")} + {field("Cl⁻", "cl_ppm")} + {field("SO₄²⁻", "so4_ppm")} + {field("HCO₃⁻", "hco3_ppm")} +
+ ); +} + +function IonField({ + label, + value, + onChange, + testId, +}: { + label: string; + value: number; + onChange: (v: number) => void; + testId?: string; +}) { + return ( + + ); +} diff --git a/apps/desktop/src/components/water/MabLoader.tsx b/apps/desktop/src/components/water/MabLoader.tsx new file mode 100644 index 0000000..72703a6 --- /dev/null +++ b/apps/desktop/src/components/water/MabLoader.tsx @@ -0,0 +1,147 @@ +import { useState } from "react"; +import { useT } from "../../data/preferences.tsx"; +import { translateError } from "../../data/errors.ts"; +import { isTauri } from "../../data/runtime.ts"; +import { + fetchCommuneAnalyses, + isValidInsee, + type CommuneAnalyses, + type WaterAnalysis, +} from "../../data/moneaudebrassage.ts"; +import { analysisToIons, MAB_CACHE_KEY, type IonProfile } from "../../data/home-water.ts"; +import { usePersistedJson } from "../../storage/index.ts"; + +/** + * Fill the ions from the moneaudebrassage.fr public analysis for a French + * commune. Desktop only: the API is cross-origin and Origin-gated, which a + * browser fetch can't satisfy. The last pull is cached for offline use. + */ +export function MabLoader({ onLoad }: { onLoad: (ions: IonProfile) => void }) { + const t = useT(); + const desktop = isTauri(); + const [open, setOpen] = useState(false); + const [code, setCode] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [cache, setCache] = usePersistedJson(MAB_CACHE_KEY, null); + + const submit = async () => { + setError(null); + setLoading(true); + try { + const res = await fetchCommuneAnalyses(code); + setCache(res); + // One network → apply straight away; several → let the brewer + // pick from the list the panel renders below. + const only = res.networks[0]; + if (res.networks.length === 1 && only) onLoad(analysisToIons(only)); + } catch (err) { + setError(translateError(err, t)); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+ + {open && ( +
+ {desktop ? ( + <> +
+ + +
+ {error &&

{error}

} + {cache && cache.networks.length > 0 && ( + onLoad(analysisToIons(a))} + /> + )} + + ) : ( +

{t("recipe.water.mab_desktop_only")}

+ )} +

+ {t("recipe.water.mab_attribution")} +

+
+ )} +
+ ); +} + +/** Per-network rows from a moneaudebrassage pull; click one to apply. */ +function MabNetworkList({ + commune, + onPick, +}: { + commune: CommuneAnalyses; + onPick: (a: WaterAnalysis) => void; +}) { + const t = useT(); + return ( +
+

+ {t("recipe.water.mab_networks", { insee: commune.insee })} +

+
+ {commune.networks.map((a, i) => ( + + ))} +
+
+ ); +} diff --git a/apps/desktop/src/data/home-water.ts b/apps/desktop/src/data/home-water.ts new file mode 100644 index 0000000..c9ce2cf --- /dev/null +++ b/apps/desktop/src/data/home-water.ts @@ -0,0 +1,57 @@ +import { usePersistedJson } from "../storage/index.ts"; +import type { WaterAnalysis } from "./moneaudebrassage.ts"; + +/** + * The brewer's own water ("My water"), set in Settings. New recipes start + * from it and every recipe's water chemistry offers it next to the city + * profiles. Local preference: not synced with recipes. + */ + +export interface IonProfile { + ca_ppm: number; + mg_ppm: number; + na_ppm: number; + cl_ppm: number; + so4_ppm: number; + hco3_ppm: number; +} + +export const ZERO_IONS: IonProfile = { + ca_ppm: 0, mg_ppm: 0, na_ppm: 0, cl_ppm: 0, so4_ppm: 0, hco3_ppm: 0, +}; + +export const HOME_WATER_KEY = "local.prefs.water"; + +/** Last moneaudebrassage.fr pull, kept so the home commune works offline. */ +export const MAB_CACHE_KEY = "local.prefs.water.mab"; + +export function useHomeWater() { + return usePersistedJson(HOME_WATER_KEY, ZERO_IONS); +} + +export function ionsEqual(a: IonProfile, b: IonProfile): boolean { + return ( + a.ca_ppm === b.ca_ppm && + a.mg_ppm === b.mg_ppm && + a.na_ppm === b.na_ppm && + a.cl_ppm === b.cl_ppm && + a.so4_ppm === b.so4_ppm && + a.hco3_ppm === b.hco3_ppm + ); +} + +/** All six ions at zero: the brewer hasn't set their water. */ +export function isUnsetWater(ions: IonProfile): boolean { + return ionsEqual(ions, ZERO_IONS); +} + +export function analysisToIons(a: WaterAnalysis): IonProfile { + return { + ca_ppm: a.ca_ppm, + mg_ppm: a.mg_ppm, + na_ppm: a.na_ppm, + cl_ppm: a.cl_ppm, + so4_ppm: a.so4_ppm, + hco3_ppm: a.hco3_ppm, + }; +} diff --git a/apps/desktop/src/data/i18n.ts b/apps/desktop/src/data/i18n.ts index 09947a3..4e24392 100644 --- a/apps/desktop/src/data/i18n.ts +++ b/apps/desktop/src/data/i18n.ts @@ -417,6 +417,11 @@ const STRINGS: Record = { "settings.section.units": { en: "Units", fr: "Unités" }, "settings.section.github": { en: "GitHub sync", fr: "Synchronisation GitHub" }, "settings.section.data": { en: "Data", fr: "Données" }, + "settings.section.water": { en: "My water", fr: "Mon eau" }, + "settings.water.intro": { + en: "The water you usually brew with. New recipes start from it, and each recipe's water chemistry offers it as “My water” next to the city profiles.", + fr: "L'eau avec laquelle vous brassez d'habitude. Les nouvelles recettes partent de celle-ci, et la chimie de l'eau de chaque recette la propose sous « Mon eau », à côté des profils de villes.", + }, "settings.units.intro": { en: "Display-only. The editor and stored data are unchanged — these preferences just change how recipes and brew screens render numbers.", fr: "Affichage uniquement. L'éditeur et les données stockées restent inchangés — ces préférences modifient seulement l'affichage des nombres.", @@ -719,8 +724,14 @@ const STRINGS: Record = { fr: "Profil d'eau source", }, "recipe.water.source_custom": { en: "Custom", fr: "Personnalisé" }, - "recipe.water.from_recipe": { en: "Use recipe's water", fr: "Utiliser l'eau de la recette" }, - "recipe.water.from_recipe_loaded": { en: "✓ recipe water loaded", fr: "✓ eau de la recette chargée" }, + "recipe.water.source_home": { en: "My water", fr: "Mon eau" }, + "recipe.water.source_home_unset": { en: "My water (not set yet)", fr: "Mon eau (pas encore configurée)" }, + "recipe.water.source_recipe": { en: "This recipe's water", fr: "Eau de la recette" }, + "recipe.water.home_hint": { + en: "Set your own water once in Settings, then pick “My water” here.", + fr: "Configurez votre eau une fois dans les réglages, puis choisissez « Mon eau » ici.", + }, + "recipe.water.open_settings": { en: "Open Settings", fr: "Ouvrir les réglages" }, "recipe.water.from_mab": { en: "Load from moneaudebrassage.fr", fr: "Charger depuis moneaudebrassage.fr" }, "recipe.water.mab_insee": { en: "INSEE code", fr: "Code INSEE" }, "recipe.water.mab_load": { en: "Load", fr: "Charger" }, @@ -734,8 +745,6 @@ const STRINGS: Record = { en: "Water data © moneaudebrassage.fr", fr: "Données sur l'eau © moneaudebrassage.fr", }, - "recipe.water.save_default": { en: "Save as default", fr: "Enregistrer par défaut" }, - "recipe.water.saved_default": { en: "✓ saved as default", fr: "✓ enregistré par défaut" }, "recipe.water.total": { en: "Total water", fr: "Eau totale" }, "recipe.water.default_volume": { en: "Default {volume} L (mash + sparge)", fr: "Défaut {volume} L (empâtage + rinçage)" }, "recipe.water.target_profile": { en: "Target profile", fr: "Profil cible" }, diff --git a/apps/desktop/src/screens/Recipe.tsx b/apps/desktop/src/screens/Recipe.tsx index 9096466..df49f30 100644 --- a/apps/desktop/src/screens/Recipe.tsx +++ b/apps/desktop/src/screens/Recipe.tsx @@ -97,9 +97,11 @@ interface RecipeScreenProps { onStartBrewing?: (() => void) | undefined; onEdit?: (() => void) | undefined; onApplyScaled?: ((scaled: BeerJsonRecipe) => void) | undefined; + /** Opens Settings, where the brewer sets their own water. */ + onOpenSettings?: (() => void) | undefined; } -export function RecipeScreen({ recipeId, recipe, activeProfile, inventory, onBack, onStartBrewing, onEdit, onApplyScaled }: RecipeScreenProps) { +export function RecipeScreen({ recipeId, recipe, activeProfile, inventory, onBack, onStartBrewing, onEdit, onApplyScaled, onOpenSettings }: RecipeScreenProps) { const hasActiveSession = useBrewSessionExists(recipeId); const prefs = useUnits(); const t = useT(); @@ -562,7 +564,7 @@ export function RecipeScreen({ recipeId, recipe, activeProfile, inventory, onBac {/* ─── Water chemistry ──────────────────────────────────────────── */} - + {/* ─── Carbonation calculator ───────────────────────────────────── */} diff --git a/apps/desktop/src/screens/Recipe/WaterChemistrySection.tsx b/apps/desktop/src/screens/Recipe/WaterChemistrySection.tsx index 7fccf53..74a6b10 100644 --- a/apps/desktop/src/screens/Recipe/WaterChemistrySection.tsx +++ b/apps/desktop/src/screens/Recipe/WaterChemistrySection.tsx @@ -1,37 +1,22 @@ -import { useState } from "react"; import { toLiters } from "@werb/adapters"; import type { BeerJsonRecipe } from "@werb/adapters"; import { computeWaterAdditions, suggestWaterAdditions } from "@werb/calc"; import type { WaterAdditionsInput, WaterAdditionsOutput } from "@werb/types"; import { SOURCE_WATER_PROFILES, type SourceWaterProfile } from "../../data/catalog/index.ts"; import { useT } from "../../data/preferences.tsx"; -import { translateError } from "../../data/errors.ts"; -import { isTauri } from "../../data/runtime.ts"; import { - fetchCommuneAnalyses, - isValidInsee, - type CommuneAnalyses, - type WaterAnalysis, -} from "../../data/moneaudebrassage.ts"; + ionsEqual, + isUnsetWater, + useHomeWater, + type IonProfile, +} from "../../data/home-water.ts"; +import { IonFields } from "../../components/water/IonFields.tsx"; import { usePersistedJson } from "../../storage/index.ts"; import { Section } from "./Section.tsx"; import { CarbField } from "./CarbFields.tsx"; import { useNumericText } from "../../components/editor/Fields.tsx"; const WATER_STORAGE_PREFIX = "werb.water."; -// Source water is the brewer's tap / RO / spring — same across recipes -// for most home brewers. Caching the latest entry as a preference auto- -// fills new recipes without forcing the user to retype. -const WATER_SOURCE_PREFS_KEY = "local.prefs.water"; - -export interface IonProfile { - ca_ppm: number; - mg_ppm: number; - na_ppm: number; - cl_ppm: number; - so4_ppm: number; - hco3_ppm: number; -} interface SaltAmounts { gypsum_g: number; @@ -42,7 +27,8 @@ interface SaltAmounts { } interface WaterFormState { - source: IonProfile; + /** null = follow My water from Settings. */ + source: IonProfile | null; target_key: string; custom_target: IonProfile | null; salts: SaltAmounts; @@ -51,10 +37,6 @@ interface WaterFormState { volume_l_override: number | null; } -const ZERO_IONS: IonProfile = { - ca_ppm: 0, mg_ppm: 0, na_ppm: 0, cl_ppm: 0, so4_ppm: 0, hco3_ppm: 0, -}; - const ZERO_SALTS: SaltAmounts = { gypsum_g: 0, calcium_chloride_g: 0, epsom_g: 0, table_salt_g: 0, baking_soda_g: 0, }; @@ -93,22 +75,6 @@ const TARGETS: Array<{ key: string; profile: IonProfile | null }> = [ { key: "dublin_stout", profile: { ca_ppm: 120, mg_ppm: 10, na_ppm: 15, cl_ppm: 70, so4_ppm: 60, hco3_ppm: 250 } }, ]; -// Persist the last moneaudebrassage pull globally (not per-recipe) so a -// brewer's home commune is one click away on the next recipe and stays -// usable offline. -const MAB_CACHE_KEY = "local.prefs.water.mab"; - -export function analysisToIons(a: WaterAnalysis): IonProfile { - return { - ca_ppm: a.ca_ppm, - mg_ppm: a.mg_ppm, - na_ppm: a.na_ppm, - cl_ppm: a.cl_ppm, - so4_ppm: a.so4_ppm, - hco3_ppm: a.hco3_ppm, - }; -} - /** * Pull source ions off a recipe's BeerJSON `water_additions` (WaterBase). * Concentrations are stored as ppm/mg-L objects; for dilute brewing @@ -145,7 +111,13 @@ const FLAVOR_HINT_KEYS: Record = { none: "—", }; -export function WaterChemistrySection({ recipe }: { recipe: BeerJsonRecipe }) { +export function WaterChemistrySection({ + recipe, + onOpenSettings, +}: { + recipe: BeerJsonRecipe; + onOpenSettings?: (() => void) | undefined; +}) { const tt = useT(); const batchL = toLiters(recipe.batch_size); @@ -154,14 +126,11 @@ export function WaterChemistrySection({ recipe }: { recipe: BeerJsonRecipe }) { // we don't have a better number; the brewer can override. const defaultVolumeL = Math.round(batchL * 1.4 * 10) / 10; - const [savedSource, setSavedSource] = usePersistedJson( - WATER_SOURCE_PREFS_KEY, - ZERO_IONS, - ); + const [homeWater] = useHomeWater(); const [form, setForm] = usePersistedJson( `${WATER_STORAGE_PREFIX}${recipe.name}`, { - source: savedSource, + source: null, target_key: "off", custom_target: null, salts: ZERO_SALTS, @@ -169,7 +138,8 @@ export function WaterChemistrySection({ recipe }: { recipe: BeerJsonRecipe }) { }, ); - const updateSource = (next: IonProfile) => + const source = form.source ?? homeWater; + const updateSource = (next: IonProfile | null) => setForm((prev) => ({ ...prev, source: next })); const updateSalt = (key: K, value: number) => @@ -188,7 +158,7 @@ export function WaterChemistrySection({ recipe }: { recipe: BeerJsonRecipe }) { if (!target) return; const out = suggestWaterAdditions({ water_volume_l: waterVolume, - source: form.source, + source, target, }); setForm((prev) => ({ @@ -205,7 +175,7 @@ export function WaterChemistrySection({ recipe }: { recipe: BeerJsonRecipe }) { const calcInput: WaterAdditionsInput = { water_volume_l: waterVolume, - source: form.source, + source, additions: { ...(form.salts.gypsum_g > 0 && { gypsum_g: form.salts.gypsum_g }), ...(form.salts.calcium_chloride_g > 0 && { @@ -226,16 +196,12 @@ export function WaterChemistrySection({ recipe }: { recipe: BeerJsonRecipe }) { >
setSavedSource(form.source)} - savedMatches={ionsEqual(form.source, savedSource)} - /> - - updateSource(null)} + homeWater={homeWater} recipeIons={recipeSourceIons(recipe)} - recipeMatches={(ions) => ionsEqual(form.source, ions)} - onLoad={updateSource} + onOpenSettings={onOpenSettings} />
@@ -331,50 +297,68 @@ function profileToIons(p: SourceWaterProfile): IonProfile { }; } +/** + * Source-water selector value: "home" (My water, from Settings), "recipe" + * (the recipe file's own water), a city preset key, or "" for custom ions. + */ +export function sourceSelection( + source: IonProfile, + homeWater: IonProfile, + recipeIons: IonProfile | null, +): string { + if (!isUnsetWater(homeWater) && ionsEqual(source, homeWater)) return "home"; + if (recipeIons && ionsEqual(source, recipeIons)) return "recipe"; + return matchingProfileKey(source); +} + function SourceWaterRow({ source, onChange, - onSaveDefault, - savedMatches, + onFollowHome, + homeWater, + recipeIons, + onOpenSettings, }: { source: IonProfile; onChange: (next: IonProfile) => void; - onSaveDefault: () => void; - savedMatches: boolean; + onFollowHome: () => void; + homeWater: IonProfile; + recipeIons: IonProfile | null; + onOpenSettings?: (() => void) | undefined; }) { const t = useT(); - const currentProfileKey = matchingProfileKey(source); + const current = sourceSelection(source, homeWater, recipeIons); + const homeSet = !isUnsetWater(homeWater); return ( <> -
-

- {t("recipe.water.source_ppm")} -

- -
+

+ {t("recipe.water.source_ppm")} +

-
- onChange({ ...source, ca_ppm: v })} /> - onChange({ ...source, mg_ppm: v })} /> - onChange({ ...source, na_ppm: v })} /> - onChange({ ...source, cl_ppm: v })} /> - onChange({ ...source, so4_ppm: v })} /> - onChange({ ...source, hco3_ppm: v })} /> -
- - ); -} - -/** - * Two one-click ways to fill the source ions without typing: - * 1. From the recipe's own BeerJSON water profile, when it carries one. - * 2. From the moneaudebrassage.fr public analysis for a French commune - * (desktop only — the API is cross-origin + Origin-gated, which a - * browser fetch can't do). The last pull is cached so the brewer's - * home commune stays one tap away and works offline. - */ -function SourceProfileLoaders({ - recipeIons, - recipeMatches, - onLoad, -}: { - recipeIons: IonProfile | null; - recipeMatches: (ions: IonProfile) => boolean; - onLoad: (ions: IonProfile) => void; -}) { - const t = useT(); - const desktop = isTauri(); - const [open, setOpen] = useState(false); - const [code, setCode] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [cache, setCache] = usePersistedJson(MAB_CACHE_KEY, null); - - const submit = async () => { - setError(null); - setLoading(true); - try { - const res = await fetchCommuneAnalyses(code); - setCache(res); - // One network → apply straight away; several → let the brewer - // pick from the list the panel renders below. - const only = res.networks[0]; - if (res.networks.length === 1 && only) onLoad(analysisToIons(only)); - } catch (err) { - setError(translateError(err, t)); - } finally { - setLoading(false); - } - }; - - return ( -
-
- {recipeIons && ( - + +

+ {t("recipe.water.home_hint")} + {onOpenSettings && ( + <> + {" "} + + )} - -

- - {open && ( -
- {desktop ? ( - <> -
- - -
- {error &&

{error}

} - {cache && cache.networks.length > 0 && ( - onLoad(analysisToIons(a))} - /> - )} - - ) : ( -

{t("recipe.water.mab_desktop_only")}

- )} -

- {t("recipe.water.mab_attribution")} -

-
- )} -
- ); -} - -/** Per-network rows from a moneaudebrassage pull; click one to apply. */ -function MabNetworkList({ - commune, - onPick, -}: { - commune: CommuneAnalyses; - onPick: (a: WaterAnalysis) => void; -}) { - const t = useT(); - return ( -
-

- {t("recipe.water.mab_networks", { insee: commune.insee })}

-
- {commune.networks.map((a, i) => ( - - ))} -
-
+ ); } @@ -653,29 +488,6 @@ function ResultStrip({ ); } -function IonField({ - label, - value, - onChange, -}: { - label: string; - value: number; - onChange: (v: number) => void; -}) { - return ( - - ); -} - function SaltField({ label, sub, @@ -708,14 +520,3 @@ function SaltField({ ); } - -function ionsEqual(a: IonProfile, b: IonProfile): boolean { - return ( - a.ca_ppm === b.ca_ppm && - a.mg_ppm === b.mg_ppm && - a.na_ppm === b.na_ppm && - a.cl_ppm === b.cl_ppm && - a.so4_ppm === b.so4_ppm && - a.hco3_ppm === b.hco3_ppm - ); -} diff --git a/apps/desktop/src/screens/Settings.tsx b/apps/desktop/src/screens/Settings.tsx index 86df07a..65af9ce 100644 --- a/apps/desktop/src/screens/Settings.tsx +++ b/apps/desktop/src/screens/Settings.tsx @@ -18,6 +18,7 @@ import type { UnitPreferences } from "../data/units-format.ts"; import { SUPPORTED_LOCALES } from "../data/i18n.ts"; import { downloadTextFile, pickAndReadTextFile } from "../data/browser-fs.ts"; import { translateError } from "../data/errors.ts"; +import { HomeWaterCard } from "./Settings/HomeWaterCard.tsx"; /** * Sync + advanced storage settings. @@ -73,6 +74,10 @@ export function SettingsScreen() { +
+ +
+
{sync ? ( setSync(null)} /> diff --git a/apps/desktop/src/screens/Settings/HomeWaterCard.tsx b/apps/desktop/src/screens/Settings/HomeWaterCard.tsx new file mode 100644 index 0000000..838d545 --- /dev/null +++ b/apps/desktop/src/screens/Settings/HomeWaterCard.tsx @@ -0,0 +1,25 @@ +import { useT } from "../../data/preferences.tsx"; +import { useHomeWater } from "../../data/home-water.ts"; +import { IonFields } from "../../components/water/IonFields.tsx"; +import { MabLoader } from "../../components/water/MabLoader.tsx"; + +/** Settings card for "My water", the brewer's own source water. */ +export function HomeWaterCard() { + const t = useT(); + const [water, setWater] = useHomeWater(); + return ( +
+

+ {t("settings.water.intro")} +

+

+ {t("recipe.water.source_ppm")} +

+ + +
+ ); +} diff --git a/apps/desktop/test/e2e/smoke.spec.ts b/apps/desktop/test/e2e/smoke.spec.ts index d43af19..85e35f0 100644 --- a/apps/desktop/test/e2e/smoke.spec.ts +++ b/apps/desktop/test/e2e/smoke.spec.ts @@ -178,6 +178,30 @@ test.describe("Water chemistry — source = target reports no delta", () => { expect(await app.recipe.offTargetIonCount()).toBe(0); }); + test("My water set in Settings is the recipe's source water", async ({ page }) => { + const app = new App(page); + await app.go("Settings"); + const ca = page.locator('[data-testid="home-water-ca_ppm"]'); + await ca.fill("42"); + await ca.blur(); + await page.waitForTimeout(250); + + await app.go("Library"); + await app.library.importSamples(); + await app.library.openFirstRecipe(); + await page.locator('[data-testid="water-chemistry"]').scrollIntoViewIfNeeded(); + expect(await page.locator('[data-testid="water-source-select"]').inputValue()).toBe("home"); + expect(await page.locator('[data-testid="water-source-ca_ppm"]').inputValue()).toBe("42"); + + // A preset replaces it; My water is one pick away. + await app.recipe.setWaterSource("burton"); + await app.recipe.setWaterSource("home"); + expect(await page.locator('[data-testid="water-source-ca_ppm"]').inputValue()).toBe("42"); + + await page.locator('[data-testid="water-open-settings"]').click(); + await page.locator('[data-testid="home-water"]').waitFor(); + }); + test("real mismatch still flags deltas (Burton vs Pilsner)", async ({ page }) => { // Guard against the city-targets fix over-collapsing comparisons. const app = new App(page); diff --git a/apps/desktop/test/water-source.test.ts b/apps/desktop/test/water-source.test.ts index 0cde283..dc04b46 100644 --- a/apps/desktop/test/water-source.test.ts +++ b/apps/desktop/test/water-source.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; import type { BeerJsonRecipe } from "@werb/adapters"; import { - analysisToIons, recipeSourceIons, + sourceSelection, } from "../src/screens/Recipe/WaterChemistrySection.tsx"; +import { analysisToIons, isUnsetWater, ZERO_IONS } from "../src/data/home-water.ts"; function recipeWith(waterAdditions: unknown[]): BeerJsonRecipe { return { @@ -91,3 +92,25 @@ describe("analysisToIons", () => { }); }); }); + +describe("sourceSelection", () => { + const home = { ca_ppm: 42, mg_ppm: 8, na_ppm: 10, cl_ppm: 20, so4_ppm: 30, hco3_ppm: 150 }; + const fromRecipe = { ca_ppm: 60, mg_ppm: 5, na_ppm: 5, cl_ppm: 40, so4_ppm: 50, hco3_ppm: 100 }; + + it("picks My water when the source matches it", () => { + expect(sourceSelection(home, home, fromRecipe)).toBe("home"); + }); + + it("picks the recipe's water when the source matches it", () => { + expect(sourceSelection(fromRecipe, home, fromRecipe)).toBe("recipe"); + }); + + it("never matches an unset (all-zero) My water", () => { + expect(isUnsetWater(ZERO_IONS)).toBe(true); + expect(sourceSelection(ZERO_IONS, ZERO_IONS, null)).not.toBe("home"); + }); + + it("falls back to custom for edited ions", () => { + expect(sourceSelection({ ...home, ca_ppm: 43 }, home, fromRecipe)).toBe(""); + }); +});