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
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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
ions or load them from moneaudebrassage.fr by INSEE commune code, in the
desktop app and in the browser. 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.
Expand Down
77 changes: 34 additions & 43 deletions apps/desktop/src/components/water/MabLoader.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
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,
Expand All @@ -13,12 +12,10 @@ 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.
* commune. 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);
Expand Down Expand Up @@ -60,45 +57,39 @@ export function MabLoader({ onLoad }: { onLoad: (ions: IonProfile) => void }) {
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>
<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="mt-3 text-caption text-text-muted opacity-70">
{t("recipe.water.mab_attribution")}
Expand Down
8 changes: 0 additions & 8 deletions apps/desktop/src/data/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,10 +736,6 @@ const STRINGS: Record<string, Entry> = {
"recipe.water.mab_insee": { en: "INSEE code", fr: "Code INSEE" },
"recipe.water.mab_load": { en: "Load", fr: "Charger" },
"recipe.water.mab_loading": { en: "Loading…", fr: "Chargement…" },
"recipe.water.mab_desktop_only": {
en: "Live commune lookup is available in the desktop app only. In the browser, enter the ions manually.",
fr: "La recherche par commune n'est disponible que dans l'application de bureau. Dans le navigateur, saisissez les ions manuellement.",
},
"recipe.water.mab_networks": { en: "Networks for {insee} — pick one", fr: "Réseaux pour {insee} — choisissez-en un" },
"recipe.water.mab_attribution": {
en: "Water data © moneaudebrassage.fr",
Expand Down Expand Up @@ -1166,10 +1162,6 @@ const STRINGS: Record<string, Entry> = {
en: "Enter a valid 5-digit INSEE commune code (e.g. 73008).",
fr: "Saisissez un code INSEE de commune à 5 chiffres (ex. 73008).",
},
"error.water.mab.web_unsupported": {
en: "Commune lookup needs the desktop app — enter the ions manually here.",
fr: "La recherche par commune nécessite l'application de bureau — saisissez les ions manuellement ici.",
},
"error.water.mab.not_found": {
en: "No published water analysis found for that commune.",
fr: "Aucune analyse d'eau publiée pour cette commune.",
Expand Down
28 changes: 9 additions & 19 deletions apps/desktop/src/data/moneaudebrassage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,9 @@
* to pre-fill the water-chemistry source ions so FR brewers don't have
* to copy six numbers by hand.
*
* The request is cross-origin and the API gates on the `Origin` header
* — neither works from a browser `fetch` (CORS rejects it, and `Origin`
* is a forbidden header the browser won't let JS set). So the fetch
* goes through the Tauri HTTP plugin (a native request, no CORS, the
* `Origin` header passes through). In the web build the feature is
* unavailable and the caller falls back to manual entry / the last
* cached pull.
* The API answers CORS requests from any origin, so the web build uses
* the browser `fetch`. The desktop app goes through the Tauri HTTP
* plugin, scoped to the API host in `capabilities/default.json`.
*
* Data © moneaudebrassage.fr — attribute the source in the UI.
*/
Expand All @@ -22,7 +18,6 @@ import { isTauri } from "./runtime.ts";
import { WerbError } from "./errors.ts";

const API_BASE = "https://apidb.moneaudebrassage.fr/lastanalyses";
const ORIGIN = "https://www.moneaudebrassage.fr";

/** One distribution network's latest ion analysis (ppm). */
export interface WaterAnalysis {
Expand Down Expand Up @@ -95,28 +90,23 @@ function toAnalysis(q: RawQuartier): WaterAnalysis {
* Fetch and parse the latest analyses for an INSEE commune code.
*
* Throws a {@link WerbError} the UI can translate:
* - `water.mab.web_unsupported` — not running under Tauri.
* - `water.mab.bad_code` — code isn't a valid INSEE code.
* - `water.mab.not_found` — commune has no published analysis.
* - `water.mab.network` — request failed (offline, 5xx, …).
* - `water.mab.bad_code` — code isn't a valid INSEE code.
* - `water.mab.not_found` — commune has no published analysis.
* - `water.mab.network` — request failed (offline, 5xx, …).
*/
export async function fetchCommuneAnalyses(rawCode: string): Promise<CommuneAnalyses> {
const code = rawCode.trim().toUpperCase();
if (!isValidInsee(code)) {
throw new WerbError("water.mab.bad_code");
}
if (!isTauri()) {
throw new WerbError("water.mab.web_unsupported");
}

// Dynamic import keeps the Tauri HTTP plugin out of the web bundle.
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
const request = isTauri() ? (await import("@tauri-apps/plugin-http")).fetch : fetch;

let res: Response;
try {
res = await tauriFetch(`${API_BASE}/${encodeURIComponent(code)}`, {
res = await request(`${API_BASE}/${encodeURIComponent(code)}`, {
method: "GET",
headers: { Accept: "application/json", Origin: ORIGIN },
headers: { Accept: "application/json" },
});
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
Expand Down
55 changes: 54 additions & 1 deletion apps/desktop/test/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
import { App } from "./pages.js";

/**
Expand Down Expand Up @@ -202,6 +202,59 @@ test.describe("Water chemistry — source = target reports no delta", () => {
await page.locator('[data-testid="home-water"]').waitFor();
});

test.describe("My water from moneaudebrassage.fr", () => {
// page.route doesn't see requests that go through a service worker.
test.use({ serviceWorkers: "block" });

const network = (name: string, ca: number) => ({
nomreseau: name,
ca,
mg: 6,
na: 6,
cl: 11,
so4: 12,
hco3: 182,
ph: 7.8,
date_ca: "2026-09-22",
});

const serveCommune = async (page: Page, quartiers: unknown[]) => {
await page.route("https://apidb.moneaudebrassage.fr/lastanalyses/*", (route) =>
route.fulfill({
contentType: "application/json",
headers: { "Access-Control-Allow-Origin": "*" },
body: JSON.stringify({ departement: "73", inseecommune: "73008", quartiers }),
}),
);
};

const lookUp = async (page: Page, code: string) => {
await page.getByRole("button", { name: /moneaudebrassage\.fr/i }).click();
const panel = page.getByTestId("water-mab-panel");
await panel.getByRole("textbox").fill(code);
await panel.getByRole("button", { name: /^(Load|Charger)$/ }).click();
return panel;
};

test("a commune with one network fills My water in the browser", async ({ page }) => {
await serveCommune(page, [network("AIX LES BAINS PORTS", 53)]);
const app = new App(page);
await app.go("Settings");
await lookUp(page, "73008");
await expect(page.getByTestId("home-water-ca_ppm")).toHaveValue("53");
await expect(page.getByTestId("home-water-hco3_ppm")).toHaveValue("182");
});

test("a commune with several networks lets the brewer pick one", async ({ page }) => {
await serveCommune(page, [network("NORTH", 40), network("SOUTH", 90)]);
const app = new App(page);
await app.go("Settings");
const panel = await lookUp(page, "73008");
await panel.getByRole("button", { name: /SOUTH/ }).click();
await expect(page.getByTestId("home-water-ca_ppm")).toHaveValue("90");
});
});

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);
Expand Down
28 changes: 16 additions & 12 deletions apps/desktop/test/moneaudebrassage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchCommuneAnalyses, isValidInsee } from "../src/data/moneaudebrassage.ts";
import { isWerbError } from "../src/data/errors.ts";

// The module fetches through the Tauri HTTP plugin (dynamic import).
// Mock the plugin so we can drive responses without a real network /
// Tauri runtime.
// Under Tauri the module fetches through the HTTP plugin (dynamic import),
// elsewhere through the global fetch. Both are mocked.
const tauriFetch = vi.fn();
const browserFetch = vi.fn();
vi.mock("@tauri-apps/plugin-http", () => ({ fetch: (...args: unknown[]) => tauriFetch(...args) }));

const TAURI_KEY = "__TAURI_INTERNALS__";
Expand Down Expand Up @@ -70,24 +70,28 @@ describe("isValidInsee", () => {
describe("fetchCommuneAnalyses", () => {
beforeEach(() => {
tauriFetch.mockReset();
browserFetch.mockReset();
vi.stubGlobal("fetch", browserFetch);
(globalThis as Record<string, unknown>).window = globalThis as unknown as Window;
(window as unknown as Record<string, unknown>)[TAURI_KEY] = {};
});

afterEach(() => {
delete (window as unknown as Record<string, unknown>)[TAURI_KEY];
vi.unstubAllGlobals();
});

it("rejects a bad code before touching the network", async () => {
await expect(fetchCommuneAnalyses("nope")).rejects.toMatchObject({ code: "water.mab.bad_code" });
expect(tauriFetch).not.toHaveBeenCalled();
});

it("refuses to run outside Tauri", async () => {
it("uses the browser fetch outside Tauri", async () => {
delete (window as unknown as Record<string, unknown>)[TAURI_KEY];
await expect(fetchCommuneAnalyses("73008")).rejects.toMatchObject({
code: "water.mab.web_unsupported",
});
browserFetch.mockResolvedValue(jsonResponse(SAMPLE));
const res = await fetchCommuneAnalyses("73008");
expect(res.networks).toHaveLength(1);
expect(browserFetch).toHaveBeenCalledOnce();
expect(tauriFetch).not.toHaveBeenCalled();
});

Expand All @@ -111,14 +115,14 @@ describe("fetchCommuneAnalyses", () => {
});
});

it("sends the Origin header the API gates on", async () => {
it("asks the API for the commune as JSON", async () => {
tauriFetch.mockResolvedValue(jsonResponse(SAMPLE));
await fetchCommuneAnalyses("73008");
const [url, opts] = tauriFetch.mock.calls[0];
expect(url).toContain("/lastanalyses/73008");
expect((opts as { headers: Record<string, string> }).headers.Origin).toBe(
"https://www.moneaudebrassage.fr",
expect(tauriFetch).toHaveBeenCalledWith(
"https://apidb.moneaudebrassage.fr/lastanalyses/73008",
{ method: "GET", headers: { Accept: "application/json" } },
);
expect(browserFetch).not.toHaveBeenCalled();
});

it("maps a 404 to not_found", async () => {
Expand Down
Loading