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(""); + }); +});