diff --git a/src/server.ts b/src/server.ts index 5ff715b..c945bf6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -239,7 +239,7 @@ export function createServer(deps: Deps) { { title: "Log Arabica brew", description: - "Create one brew for an exact bean AT-URI owned by the authenticated user. Pass recipeRef to base the brew on an exact recipe AT-URI owned by the authenticated user: its dose, water, pours, and brewer are copied into the brew, while explicitly supplied brew fields override those defaults. The recipe reference is also stored on the brew. Use tastingNotes for free-form description or notes; brews do not have a separate description field.", + "Create one brew for an exact bean AT-URI owned by the authenticated user. Pass recipeRef to base the brew on an exact recipe AT-URI owned by the authenticated user: its dose, water, pours, and brewer are copied into the brew, while explicitly supplied brew fields override those defaults. For pour-over recipes the first pour also seeds the pourover bloom (water and seconds). The recipe reference is also stored on the brew. Use tastingNotes for free-form description or notes; brews do not have a separate description field.", inputSchema: { requestId, beanUri: z.string(), @@ -298,7 +298,7 @@ export function createServer(deps: Deps) { { title: "Edit Arabica brew", description: - "Update an existing brew at its exact AT-URI. This uses putRecord and never creates a new record. Pass recipeRef to rebase the brew on an exact recipe AT-URI owned by the authenticated user: its dose, water, pours, and brewer replace the current recipe-backed fields, while explicitly supplied brew fields override those defaults. The recipe reference is stored on the brew. Supply only fields to change; pass null to clear any optional field (method, temperature, waterAmount, coffeeAmount, timeSeconds, grindSize, grinderRef, brewerRef, recipeRef, tastingNotes, rating, pours, espresso, pourover). Passing recipeRef: null clears only the recipe reference, and omitting recipeRef preserves the current recipe-backed fields. createdAt can be updated but not cleared; beanRef cannot be changed. Use tastingNotes for free-form description or notes; brews do not have a separate description field. grinderRef is the grinder record URI, while grindSize is the separate grind setting; ask for grindSize if it was not provided. A record that fails lexicon validation can be repaired here: supply corrected fields; the merged record must validate.", + "Update an existing brew at its exact AT-URI. This uses putRecord and never creates a new record. Pass recipeRef to rebase the brew on an exact recipe AT-URI owned by the authenticated user: its dose, water, pours, and brewer replace the current recipe-backed fields, while explicitly supplied brew fields override those defaults. For pour-over recipes the first pour also seeds the pourover bloom (water and seconds). The recipe reference is stored on the brew. Supply only fields to change; pass null to clear any optional field (method, temperature, waterAmount, coffeeAmount, timeSeconds, grindSize, grinderRef, brewerRef, recipeRef, tastingNotes, rating, pours, espresso, pourover). Passing recipeRef: null clears only the recipe reference, and omitting recipeRef preserves the current recipe-backed fields. createdAt can be updated but not cleared; beanRef cannot be changed. Use tastingNotes for free-form description or notes; brews do not have a separate description field. grinderRef is the grinder record URI, while grindSize is the separate grind setting; ask for grindSize if it was not provided. A record that fails lexicon validation can be repaired here: supply corrected fields; the merged record must validate.", inputSchema: { requestId, brewUri: z.string(), diff --git a/src/tools/operations.ts b/src/tools/operations.ts index c80a1c0..c86ff59 100644 --- a/src/tools/operations.ts +++ b/src/tools/operations.ts @@ -2,9 +2,11 @@ import { is, safeParse } from "@atcute/lexicons"; import { SocialArabicaAlphaBean, SocialArabicaAlphaBrew, + SocialArabicaAlphaBrewer, SocialArabicaAlphaRecipe, SocialArabicaAlphaRoaster, BEAN_COLLECTION, + BREWER_COLLECTION, BREW_COLLECTION, RECIPE_COLLECTION, ROASTER_COLLECTION, @@ -40,9 +42,17 @@ async function session(deps: Deps) { type BrewRecipeDefaults = Pick< BrewInput, - "coffeeAmount" | "waterAmount" | "pours" | "brewerRef" + "coffeeAmount" | "waterAmount" | "pours" | "brewerRef" | "pourover" >; +/** Brewer type strings that count as pour-over, like the frontend's normalizeBrewerCategory. */ +const POUROVER_BREWER_TYPES = new Set([ + "pourover", + "pour-over", + "pour over", + "dripper", +]); + async function resolveRecipeDefaults( recipeValue: string, s: Awaited>, @@ -84,6 +94,46 @@ async function resolveRecipeDefaults( "invalid_record", "The selected recipe record is malformed.", ); + // Bloom derivation is best-effort: when the recipe has no brewerType, fall + // back to the referenced brewer's type like the frontend; a missing or + // malformed brewer record just skips the bloom. + let brewerType = checked.value.brewerType; + if (!brewerType && checked.value.brewerRef) { + try { + const brewerRef = ownedRecordUri( + checked.value.brewerRef, + s.did, + BREWER_COLLECTION, + "brewerRef", + "brewer", + ); + const brewer = await deps + .pds(s) + .getRecord(BREWER_COLLECTION, brewerRef.rkey, signal); + const parsed = safeParse( + SocialArabicaAlphaBrewer.mainSchema, + brewer.value, + ); + if (parsed.ok) brewerType = parsed.value.brewerType; + } catch {} + } + // For pour-over recipes the first pour seeds the bloom: its water becomes + // bloomWater and its time becomes bloomSeconds. + const firstPour = checked.value.pours?.[0]; + const pourover = + brewerType && + POUROVER_BREWER_TYPES.has(brewerType.toLowerCase().trim()) && + firstPour && + (firstPour.waterAmount > 0 || firstPour.timeSeconds > 0) + ? { + ...(firstPour.waterAmount > 0 + ? { bloomWater: firstPour.waterAmount } + : {}), + ...(firstPour.timeSeconds > 0 + ? { bloomSeconds: firstPour.timeSeconds } + : {}), + } + : undefined; return { coffeeAmount: checked.value.coffeeAmount && checked.value.coffeeAmount > 0 @@ -95,6 +145,7 @@ async function resolveRecipeDefaults( : undefined, pours: checked.value.pours?.map((pour) => ({ ...pour })), brewerRef: checked.value.brewerRef, + pourover, }; } @@ -268,6 +319,8 @@ export async function logBrew( pours: input.pours === undefined ? defaults.pours : input.pours, brewerRef: input.brewerRef === undefined ? defaults.brewerRef : input.brewerRef, + pourover: + input.pourover === undefined ? defaults.pourover : input.pourover, }; } let record; @@ -353,6 +406,7 @@ export async function editBrew( "waterAmount", "pours", "brewerRef", + "pourover", ] as const) { if ( !Object.prototype.hasOwnProperty.call(input, key) && @@ -392,6 +446,9 @@ export async function editBrew( "pours", "brewerRef", ]); + // Only a recipe that actually derives a bloom owns pouroverParams; a + // non-pour-over recipe leaves the brew's pourover params untouched. + if (recipeDefaults?.pourover) recipeFields.add("pourover"); for (const [inputKey, recordKey] of fields) { const supplied = Object.prototype.hasOwnProperty.call(input, inputKey); if (supplied || (rebaseRecipe && recipeFields.has(inputKey))) { diff --git a/test/unit/catalog.test.ts b/test/unit/catalog.test.ts index 1522c12..abcb6f1 100644 --- a/test/unit/catalog.test.ts +++ b/test/unit/catalog.test.ts @@ -15,6 +15,7 @@ import { import { IdempotencyStore } from "../../src/state/idempotency.js"; import { BREW_COLLECTION, + BREWER_COLLECTION, RECIPE_COLLECTION, } from "../../src/generated/lexicons.js"; import { editBrew, logBrew, type Deps } from "../../src/tools/operations.js"; @@ -299,15 +300,22 @@ describe("editBrew clears optional params", () => { value: collection === BREW_COLLECTION ? currentBrew - : { - $type: RECIPE_COLLECTION, - name: "V60", - brewerRef: brewerURI, - coffeeAmount: 185, - waterAmount: 3000, - pours: [{ waterAmount: 300, timeSeconds: 180 }], - createdAt: new Date().toISOString(), - }, + : collection === BREWER_COLLECTION + ? { + $type: "social.arabica.alpha.brewer", + name: "V60", + brewerType: "Pour Over", + createdAt: new Date().toISOString(), + } + : { + $type: RECIPE_COLLECTION, + name: "V60", + brewerRef: brewerURI, + coffeeAmount: 185, + waterAmount: 3000, + pours: [{ waterAmount: 300, timeSeconds: 180 }], + createdAt: new Date().toISOString(), + }, }), createRecord: async () => { throw new Error("unexpected"); @@ -341,6 +349,7 @@ describe("editBrew clears optional params", () => { coffeeAmount: 20, waterAmount: 300, pours: [{ waterAmount: 300, timeSeconds: 180 }], + pouroverParams: { bloomWater: 300, bloomSeconds: 180 }, }); await editBrew(