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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
59 changes: 59 additions & 0 deletions apps/desktop/src/components/water/IonFields.tsx
Original file line number Diff line number Diff line change
@@ -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="<prefix>-<ion key>"`. */
testIdPrefix?: string;
}) {
const field = (label: string, key: keyof IonProfile) => (
<IonField
label={label}
value={ions[key]}
onChange={(v) => onChange({ ...ions, [key]: v })}
{...(testIdPrefix && { testId: `${testIdPrefix}-${key}` })}
/>
);
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-2">
{field("Ca²⁺", "ca_ppm")}
{field("Mg²⁺", "mg_ppm")}
{field("Na⁺", "na_ppm")}
{field("Cl⁻", "cl_ppm")}
{field("SO₄²⁻", "so4_ppm")}
{field("HCO₃⁻", "hco3_ppm")}
</div>
);
}

function IonField({
label,
value,
onChange,
testId,
}: {
label: string;
value: number;
onChange: (v: number) => void;
testId?: string;
}) {
return (
<label className="block">
<span className="block text-caption font-mono text-text-muted mb-1">{label}</span>
<input
type="number"
min={0}
step={1}
data-testid={testId}
{...useNumericText(value, (n) => onChange(Math.max(0, n)))}
className="w-full bg-bg border border-border rounded-lg px-2 py-1.5 text-body-sm font-mono tabular-nums text-text focus:outline-none focus:border-accent [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
/>
</label>
);
}
147 changes: 147 additions & 0 deletions apps/desktop/src/components/water/MabLoader.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [cache, setCache] = usePersistedJson<CommuneAnalyses | null>(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 (
<div className="mt-3 flex flex-col gap-2">
<div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="text-caption font-medium text-accent hover:opacity-80 transition-opacity"
>
{t("recipe.water.from_mab")}
</button>
</div>

{open && (
<div
data-testid="water-mab-panel"
className="rounded-lg bg-surface-raised border border-border p-3"
>
{desktop ? (
<>
<div className="flex flex-wrap items-end gap-2">
<label className="block">
<span className="block text-caption uppercase tracking-widest text-text-muted mb-1">
{t("recipe.water.mab_insee")}
</span>
<input
type="text"
inputMode="numeric"
maxLength={5}
value={code}
placeholder="73008"
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && isValidInsee(code) && !loading) submit();
}}
className="w-28 bg-bg border border-border rounded-lg px-2 py-1.5 text-body-sm font-mono tabular-nums text-text focus:outline-none focus:border-accent"
/>
</label>
<button
type="button"
onClick={submit}
disabled={loading || !isValidInsee(code)}
className="rounded-lg bg-accent text-bg px-3 py-1.5 text-body-sm font-medium hover:opacity-90 disabled:opacity-40 disabled:cursor-default transition-opacity"
>
{loading ? t("recipe.water.mab_loading") : t("recipe.water.mab_load")}
</button>
</div>
{error && <p className="mt-2 text-caption text-warning">{error}</p>}
{cache && cache.networks.length > 0 && (
<MabNetworkList
commune={cache}
onPick={(a) => onLoad(analysisToIons(a))}
/>
)}
</>
) : (
<p className="text-caption text-text-muted">{t("recipe.water.mab_desktop_only")}</p>
)}
<p className="mt-3 text-caption text-text-muted opacity-70">
{t("recipe.water.mab_attribution")}
</p>
</div>
)}
</div>
);
}

/** 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 (
<div className="mt-3">
<p className="text-caption uppercase tracking-widest text-text-muted mb-2">
{t("recipe.water.mab_networks", { insee: commune.insee })}
</p>
<div className="flex flex-col gap-1">
{commune.networks.map((a, i) => (
<button
key={`${a.network}-${i}`}
type="button"
onClick={() => onPick(a)}
className="text-left rounded-lg border border-border px-3 py-2 hover:border-accent transition-colors"
>
<span className="block text-body-sm text-text">
{a.network}
{a.date ? <span className="text-text-muted"> · {a.date}</span> : null}
</span>
<span className="block text-caption font-mono text-text-muted mt-0.5">
Ca {a.ca_ppm.toFixed(0)} · Mg {a.mg_ppm.toFixed(0)} · Na {a.na_ppm.toFixed(0)} · Cl{" "}
{a.cl_ppm.toFixed(0)} · SO₄ {a.so4_ppm.toFixed(0)} · HCO₃ {a.hco3_ppm.toFixed(0)}
</span>
</button>
))}
</div>
</div>
);
}
57 changes: 57 additions & 0 deletions apps/desktop/src/data/home-water.ts
Original file line number Diff line number Diff line change
@@ -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<IonProfile>(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,
};
}
17 changes: 13 additions & 4 deletions apps/desktop/src/data/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,11 @@ const STRINGS: Record<string, Entry> = {
"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.",
Expand Down Expand Up @@ -719,8 +724,14 @@ const STRINGS: Record<string, Entry> = {
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" },
Expand All @@ -734,8 +745,6 @@ const STRINGS: Record<string, Entry> = {
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" },
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/screens/Recipe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -562,7 +564,7 @@ export function RecipeScreen({ recipeId, recipe, activeProfile, inventory, onBac
<YeastPitchSection recipe={recipe} />

{/* ─── Water chemistry ──────────────────────────────────────────── */}
<WaterChemistrySection recipe={recipe} />
<WaterChemistrySection recipe={recipe} onOpenSettings={onOpenSettings} />

{/* ─── Carbonation calculator ───────────────────────────────────── */}
<CarbonationSection recipe={recipe} />
Expand Down
Loading
Loading