diff --git a/apps/api/prisma/migrations/20260820140000_step_tech_step_span/migration.sql b/apps/api/prisma/migrations/20260820140000_step_tech_step_span/migration.sql new file mode 100644 index 0000000..696220e --- /dev/null +++ b/apps/api/prisma/migrations/20260820140000_step_tech_step_span/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "step_tech_step" ADD COLUMN "end" INTEGER, +ADD COLUMN "start" INTEGER; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5514042..9cc9ad8 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -672,13 +672,26 @@ model Step { /// `melt`), which is why this replaced the original single nullable /// `Step.techStepId` FK (per PR review feedback on the first version of /// this feature). `order` is the position within *this step* (0-based, in -/// the order `matchTechSteps` — `tech-step-matcher.ts` — detected the +/// the order `matchTechStepSpans` — `tech-step-matcher.ts` — detected the /// techniques in the description), not a global ordering across different /// steps of the recipe (that's `Step.order`). +/// +/// `start`/`end` are the matched span within `Step.description` (see +/// `TechStepMatch`, `tech-step-matcher.ts`) — what the recipe detail view +/// highlights. Nullable, **not backfilled**: adding them `NOT NULL` without +/// a default would fail outright against any pre-existing row, the same +/// mistake the `ingredient_unit_catalog` migration made against real prod +/// data. A row from before this column existed just has no span (no +/// highlight) until its recipe is next saved, which recomputes every step's +/// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes +/// and recreates every `Step`/`StepTechStep`, never a partial patch) — +/// graceful degradation, not a permanent gap. model StepTechStep { stepId Int @map("step_id") techStepId Int @map("tech_step_id") order Int + start Int? + end Int? step Step @relation(fields: [stepId], references: [id], onDelete: Cascade) techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade) diff --git a/apps/api/src/lib/tech-step-matcher.ts b/apps/api/src/lib/tech-step-matcher.ts index d1aec2f..6803b2e 100644 --- a/apps/api/src/lib/tech-step-matcher.ts +++ b/apps/api/src/lib/tech-step-matcher.ts @@ -4,21 +4,23 @@ import { prisma } from "../db/prisma.js"; * Auto-detects which cooking techniques (`TechStep`) a free-text recipe * step description corresponds to, using the static `TechStepMapping` * catalog (see `reference-seed-data.ts`'s `TECH_STEPS`) — groundwork for a - * future batch-cooking optimization algorithm, not surfaced in the recipe - * UI yet (see `StepView` in `packages/shared`). + * future batch-cooking optimization algorithm, and (via `matchTechStepSpans`) + * what `recipe.service.ts` persists as `StepTechStep.start`/`end` so the + * recipe UI can highlight the exact matched words (see `StepView` in + * `packages/shared`). * * A single instruction can genuinely involve more than one technique (e.g. * "Dans une poêle chaude, faire chauffer une noix de beurre" is both - * `preheat` and `melt`) — `matchTechSteps` returns the whole *ordered - * sequence* it finds, not a single winner, matching `Step.techSteps` - * (schema.prisma's `StepTechStep`, an ordered join table). + * `preheat` and `melt`) — both `matchTechSteps`/`matchTechStepSpans` return + * the whole *ordered sequence* they find, not a single winner, matching + * `Step.techSteps` (schema.prisma's `StepTechStep`, an ordered join table). * - * `normalizeText`/`matchTechSteps` are pure (no DB access) so they can be - * unit-tested in isolation (see `test/tech-step-matcher.test.ts`). - * `loadTechStepMappingRules` is the only DB-touching piece, kept separate - * so callers (`recipe.service.ts`) fetch the whole mapping list once per - * request and pass it to `matchTechSteps` per step, rather than querying - * once per step. + * `normalizeText`/`matchTechStepSpans`/`matchTechSteps` are pure (no DB + * access) so they can be unit-tested in isolation (see + * `test/tech-step-matcher.test.ts`). `loadTechStepMappingRules` is the only + * DB-touching piece, kept separate so callers (`recipe.service.ts`) fetch + * the whole mapping list once per request and pass it to + * `matchTechStepSpans` per step, rather than querying once per step. */ /** One `TechStepMapping` row, trimmed to what {@link matchTechSteps} needs. */ @@ -46,7 +48,7 @@ export function normalizeText(text: string): string { return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase(); } -/** Where in the (normalized) description one mapping matched, alongside the rule that matched — the raw material {@link matchTechSteps} resolves into a final sequence. */ +/** Where in the (normalized) description one mapping matched, alongside the rule that matched — the raw material {@link matchTechStepSpans} resolves into a final sequence. */ interface MatchCandidate extends TechStepMappingRule { start: number; end: number; @@ -57,9 +59,23 @@ function overlaps(a: MatchCandidate, b: MatchCandidate): boolean { return a.start < b.end && b.start < a.end; } +/** + * One technique {@link matchTechStepSpans} found, alongside exactly where in + * `description` it matched — `[start, end)`, same convention as + * `String.prototype.slice`. Persisted as `StepTechStep.start`/`end` + * (`recipe.service.ts`) so the recipe detail view can highlight the exact + * matched words, not just know a technique was mentioned somewhere. + */ +export interface TechStepMatch { + techStepId: number; + start: number; + end: number; +} + /** * Detects every technique `description` mentions among `mappings`, as an - * ordered sequence of `techStepId`s — empty if none match. The algorithm: + * ordered sequence of matches (each carrying *where* it matched) — empty if + * none match. The algorithm: * * 1. Test every mapping against the normalized description; each one that * matches becomes a candidate carrying *where* it matched (so @@ -81,13 +97,26 @@ function overlaps(a: MatchCandidate, b: MatchCandidate): boolean { * 4. Sort what's left by where it appears in the text — the sequence * reads in the same order as the instruction itself. * + * The returned `start`/`end` are offsets into `normalizeText(description)`, + * used as-is against the *original* `description` by callers that slice it + * for display (`highlight-tech-steps.ts`, apps/web) — `normalizeText` only + * strips diacritics/lowercases, which preserves character count for + * realistic French text (canonical NFD decomposition never turns one + * character into more than one base character), so this holds in practice. + * A pathological input where it doesn't (e.g. a bare standalone `^`, which + * `normalizeText` would strip as a diacritic) just produces a slightly + * misplaced highlight — degrades silently, doesn't crash. + * * Pure — takes `mappings` as a plain argument rather than querying Prisma * itself, so it's testable without a database (see * `loadTechStepMappingRules` for the DB-backed loader). `mappings` should * already be filtered to the locale the caller cares about — this function * has no notion of locale, it just tests the rules it's given. */ -export function matchTechSteps(description: string, mappings: TechStepMappingRule[]): number[] { +export function matchTechStepSpans( + description: string, + mappings: TechStepMappingRule[], +): TechStepMatch[] { const normalizedDescription = normalizeText(description); const candidates: MatchCandidate[] = []; @@ -123,7 +152,18 @@ export function matchTechSteps(description: string, mappings: TechStepMappingRul // Step 4: reading order. accepted.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId); - return accepted.map((candidate) => candidate.techStepId); + return accepted.map(({ techStepId, start, end }) => ({ techStepId, start, end })); +} + +/** + * Convenience wrapper around {@link matchTechStepSpans} for callers that + * only care about *which* techniques matched, not where — e.g. + * `recipe-translation.ts`'s `translateRecipeSteps`, which declares a step's + * technique sequence for an imported recipe that isn't saved (and so has no + * `StepTechStep` row to persist a span into) yet. + */ +export function matchTechSteps(description: string, mappings: TechStepMappingRule[]): number[] { + return matchTechStepSpans(description, mappings).map((match) => match.techStepId); } /** diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 6e44e07..a4a3dcb 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -8,12 +8,13 @@ import { type RecipeSummaryView, type RecipeTab, type RecipeView, + type StepTechStepView, type UnitView, type UpdateRecipeInput, } from "@batch-cooking/shared"; import type { Prisma } from "@prisma/client"; import { prisma } from "../../db/prisma.js"; -import { loadTechStepMappingRules, matchTechSteps } from "../../lib/tech-step-matcher.js"; +import { loadTechStepMappingRules, matchTechStepSpans } from "../../lib/tech-step-matcher.js"; // No user-language preference exists anywhere in the app yet (a single // "fr" translation file, no locale field on User/UserProfile) — steps are @@ -36,7 +37,10 @@ function recipeInclude(viewerId: number) { unit: true, }, }, - steps: { orderBy: { order: "asc" } }, + steps: { + orderBy: { order: "asc" }, + include: { techSteps: { orderBy: { order: "asc" }, include: { techStep: true } } }, + }, diets: { include: { diet: true } }, favoritedBy: { where: { userProfileId: viewerId } }, } satisfies Prisma.RecipeInclude; @@ -103,6 +107,28 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView { }; } +/** + * Shapes a step's `StepTechStep` rows into {@link StepTechStepView}s — a row + * whose `start`/`end` is still `null` (a pre-existing row saved before this + * column existed, not yet recomputed by a resave — see the schema doc + * comment on `StepTechStep`) is dropped rather than surfaced with a null + * span, so the frontend only ever deals with real, highlightable matches. + */ +function toStepTechStepViews( + techSteps: RecipeWithDetails["steps"][number]["techSteps"], +): StepTechStepView[] { + const views: StepTechStepView[] = []; + for (const stepTechStep of techSteps) { + if (stepTechStep.start === null || stepTechStep.end === null) continue; + views.push({ + techStep: { id: stepTechStep.techStep.id, key: stepTechStep.techStep.key }, + start: stepTechStep.start, + end: stepTechStep.end, + }); + } + return views; +} + /** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */ function toRecipeView(recipe: RecipeWithDetails): RecipeView { const ingredients = recipe.ingredients.map((recipeIngredient) => ({ @@ -118,6 +144,7 @@ function toRecipeView(recipe: RecipeWithDetails): RecipeView { description: step.description, picture: step.picture, order: step.order, + techSteps: toStepTechStepViews(step.techSteps), })), }; } @@ -365,8 +392,10 @@ export async function createRecipe( picture: step.picture ?? null, order: index, techSteps: { - create: matchTechSteps(step.description, techStepMappings).map((techStepId, order) => ({ - techStepId, + create: matchTechStepSpans(step.description, techStepMappings).map((match, order) => ({ + techStepId: match.techStepId, + start: match.start, + end: match.end, order, })), }, @@ -429,9 +458,11 @@ export async function updateRecipe( picture: step.picture ?? null, order: index, techSteps: { - create: matchTechSteps(step.description, techStepMappings).map( - (techStepId, order) => ({ - techStepId, + create: matchTechStepSpans(step.description, techStepMappings).map( + (match, order) => ({ + techStepId: match.techStepId, + start: match.start, + end: match.end, order, }), ), diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index d3740ba..1ad66cb 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -377,19 +377,27 @@ describe("Recipes", () => { const tomate = await ingredientId("tomato"); const piece = await unitId("piece"); const simmer = await techStepId("simmer"); + const description = "Faire mijoter à feu doux pendant 30 minutes"; const res = await agent.post("/recipes").send({ name: "Ragoût", portions: 4, dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], - steps: [{ description: "Faire mijoter à feu doux pendant 30 minutes" }], + steps: [{ description }], }); expect(res.status).to.equal(201); - // Not in the API response (see StepView) — check via Prisma directly. const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } }); expect(await stepTechStepIds(step.id)).to.deep.equal([simmer]); + // Exposed via StepView too — the whole point of persisting start/end + // (see tech-step-matcher.ts's matchTechStepSpans) is that the API + // response itself carries exactly what to highlight, not just the id. + const resStep = res.body.steps[0]; + expect(resStep.techSteps).to.have.length(1); + expect(resStep.techSteps[0].techStep).to.deep.equal({ id: simmer, key: "simmer" }); + const { start, end } = resStep.techSteps[0]; + expect(description.slice(start, end).toLowerCase()).to.equal("mijoter"); }); it("leaves a step's technique sequence empty when its description matches no known technique", async () => { @@ -466,6 +474,7 @@ describe("Recipes", () => { const piece = await unitId("piece"); const preheat = await techStepId("preheat"); const melt = await techStepId("melt"); + const description = "Préchauffer la poêle, puis faire fondre le beurre"; const res = await agent.post("/recipes").send({ name: "Poêlée", @@ -473,12 +482,25 @@ describe("Recipes", () => { dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], // The case that motivated the sequence model: one instruction, two techniques. - steps: [{ description: "Préchauffer la poêle, puis faire fondre le beurre" }], + steps: [{ description }], }); expect(res.status).to.equal(201); const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } }); expect(await stepTechStepIds(step.id)).to.deep.equal([preheat, melt]); + // Each entry's span, sliced back out of the description, is exactly + // the word(s) that triggered that particular match. + const resTechSteps = res.body.steps[0].techSteps; + expect(resTechSteps.map((t: { techStep: { key: string } }) => t.techStep.key)).to.deep.equal([ + "preheat", + "melt", + ]); + expect(description.slice(resTechSteps[0].start, resTechSteps[0].end).toLowerCase()).to.equal( + "préchauffer", + ); + expect(description.slice(resTechSteps[1].start, resTechSteps[1].end).toLowerCase()).to.equal( + "faire fondre", + ); }); it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => { @@ -719,6 +741,82 @@ describe("Recipes", () => { expect(await stepTechStepIds(step.id)).to.deep.equal([mince]); }); + it("recomputes techniques from scratch on every edit — modifying, adding, and removing a step all take effect, nothing stale survives", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); + const chop = await techStepId("chop"); + const mince = await techStepId("mince"); + const melt = await techStepId("melt"); + const simmer = await techStepId("simmer"); + const bake = await techStepId("bake"); + + const created = await agent.post("/recipes").send({ + name: "Ragoût", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], + steps: [ + { description: "Hacher les oignons" }, // chop + { description: "Faire mijoter à feu doux" }, // simmer + { description: "Cuire au four" }, // bake + ], + }); + expect(created.status).to.equal(201); + const originalStepIds = ( + await prisma.step.findMany({ where: { recipeId: created.body.id } }) + ).map((s) => s.id); + expect(originalStepIds).to.have.length(3); + // Sanity check before the edit — each original step really did get a + // techStepId chop/simmer/bake (proves the later assertions are + // actually about recomputation, not about it never having matched). + expect((await Promise.all(originalStepIds.map(stepTechStepIds))).flat().sort()).to.deep.equal( + [chop, simmer, bake].sort(), + ); + + // Edit: step 1's description changes (chop -> mince), a brand new + // step 2 is added (-> melt), and the old steps 2/3 (simmer/bake) are + // dropped entirely — the three cases the recompute guarantee has to + // cover (see StepTechStep's schema doc comment). + const editedDescription = "Émincer les tomates"; + const addedDescription = "Faire fondre le beurre"; + const res = await agent.patch(`/recipes/${created.body.id}`).send({ + name: "Ragoût", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], + steps: [{ description: editedDescription }, { description: addedDescription }], + }); + + expect(res.status).to.equal(200); + expect(res.body.steps).to.have.length(2); + expect( + res.body.steps.map((s: { techSteps: { techStep: { key: string } }[] }) => + s.techSteps.map((t) => t.techStep.key), + ), + ).to.deep.equal([["mince"], ["melt"]]); + // Modified step's span reflects the NEW text, not a stale one from + // "Hacher les oignons" (which doesn't even contain "émincer"). + const editedTechStep = res.body.steps[0].techSteps[0]; + expect( + editedDescription.slice(editedTechStep.start, editedTechStep.end).toLowerCase(), + ).to.equal("émincer"); + const addedTechStep = res.body.steps[1].techSteps[0]; + expect(addedDescription.slice(addedTechStep.start, addedTechStep.end).toLowerCase()).to.equal( + "faire fondre", + ); + + // The dropped steps' old rows are actually gone (cascade), not just + // invisible in the response — confirms "delete" really deletes rather + // than orphaning StepTechStep rows nothing references any more. + const remainingSteps = await prisma.step.findMany({ where: { recipeId: created.body.id } }); + expect(remainingSteps).to.have.length(2); + const orphanedTechSteps = await prisma.stepTechStep.findMany({ + where: { stepId: { in: originalStepIds } }, + }); + expect(orphanedTechSteps).to.deep.equal([]); + }); + it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); diff --git a/apps/api/test/tech-step-matcher.test.ts b/apps/api/test/tech-step-matcher.test.ts index acd309a..624f148 100644 --- a/apps/api/test/tech-step-matcher.test.ts +++ b/apps/api/test/tech-step-matcher.test.ts @@ -3,6 +3,7 @@ import { prisma } from "../src/db/prisma.js"; import { type TechStepMappingRule, loadTechStepMappingRules, + matchTechStepSpans, matchTechSteps, normalizeText, } from "../src/lib/tech-step-matcher.js"; @@ -151,6 +152,71 @@ describe("tech-step-matcher", () => { }); }); + describe("matchTechStepSpans", () => { + // Same fixtures as `matchTechSteps` above (kept local to this describe + // block rather than shared — each block's fixtures should be readable + // on their own). + const simmer: TechStepMappingRule = { + techStepId: 1, + expression: "\\bmijot(er|ez|e|ant|é)\\b", + weight: 15, + }; + const cook: TechStepMappingRule = { + techStepId: 2, + expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b", + weight: 10, + }; + const bake: TechStepMappingRule = { + techStepId: 3, + expression: + "\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b", + weight: 25, + }; + const preheat: TechStepMappingRule = { + techStepId: 4, + expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b", + weight: 20, + }; + const melt: TechStepMappingRule = { + techStepId: 5, + expression: "\\bfondre\\b|\\bfaire fondre\\b|\\bfaites fondre\\b", + weight: 15, + }; + + it("returns the matched span alongside the techStepId for a simple match", () => { + // "Faire mijoter à feu doux" — "mijoter" starts right after "Faire ". + expect(matchTechStepSpans("Faire mijoter à feu doux", [simmer])).to.deep.equal([ + { techStepId: 1, start: 6, end: 13 }, + ]); + }); + + it("returns an empty list when nothing matches", () => { + expect(matchTechStepSpans("Servir immédiatement", [simmer, cook, bake])).to.deep.equal([]); + }); + + it("returns each distinct technique's own span, in reading order", () => { + const text = "Préchauffer la poêle, puis faire fondre le beurre"; + const result = matchTechStepSpans(text, [preheat, melt]); + expect(result).to.have.length(2); + expect(result[0].techStepId).to.equal(4); + expect(result[1].techStepId).to.equal(5); + // Each span, sliced back out of the original text, is exactly the + // word(s) that triggered that match — what the frontend needs to + // highlight the right characters. + expect(text.slice(result[0].start, result[0].end).toLowerCase()).to.equal("préchauffer"); + expect(text.slice(result[1].start, result[1].end).toLowerCase()).to.equal("faire fondre"); + }); + + it("keeps only the winning span when two techniques' expressions overlap", () => { + // `bake` (weight 25) wins over `cook` (weight 10) for "cuire au four" + // — only bake's span survives, not two overlapping entries. + const text = "Cuire au four pendant 30 minutes"; + const result = matchTechStepSpans(text, [cook, bake]); + expect(result).to.deep.equal([{ techStepId: 3, start: 0, end: 13 }]); + expect(text.slice(0, 13)).to.equal("Cuire au four"); + }); + }); + describe("loadTechStepMappingRules", () => { beforeEach(async () => { await resetDatabase(); diff --git a/apps/web/cypress/component/highlight-tech-steps.cy.tsx b/apps/web/cypress/component/highlight-tech-steps.cy.tsx new file mode 100644 index 0000000..952e301 --- /dev/null +++ b/apps/web/cypress/component/highlight-tech-steps.cy.tsx @@ -0,0 +1,100 @@ +import type { StepTechStepView } from "@batch-cooking/shared"; +import { splitDescriptionByTechSteps } from "../../src/features/recipes/highlight-tech-steps"; + +// Pure logic, no DOM/mount needed — reuses the component-test runner +// (Cypress's Mocha/Chai, same as CheckboxOption.cy.tsx) purely for its +// `expect`, not for rendering. `.cy.tsx` (not `.cy.ts`) only because that's +// what `cypress.config.ts`'s component `specPattern` looks for. + +function techStep(key: string, id: number, start: number, end: number): StepTechStepView { + return { techStep: { id, key }, start, end }; +} + +describe("splitDescriptionByTechSteps", () => { + it("returns the whole description as one plain segment when there are no matches", () => { + expect(splitDescriptionByTechSteps("Servir immédiatement", [])).to.deep.equal([ + { text: "Servir immédiatement", techStep: null }, + ]); + }); + + it("splits a single match into before/match/after segments", () => { + // "Faire mijoter à feu doux" — "mijoter" is [6, 13). + const result = splitDescriptionByTechSteps("Faire mijoter à feu doux", [ + techStep("simmer", 1, 6, 13), + ]); + expect(result).to.deep.equal([ + { text: "Faire ", techStep: null }, + { text: "mijoter", techStep: { id: 1, key: "simmer" } }, + { text: " à feu doux", techStep: null }, + ]); + }); + + it("handles a match at the very start, with nothing before it", () => { + const result = splitDescriptionByTechSteps("Hacher les oignons", [techStep("chop", 2, 0, 6)]); + expect(result).to.deep.equal([ + { text: "Hacher", techStep: { id: 2, key: "chop" } }, + { text: " les oignons", techStep: null }, + ]); + }); + + it("handles a match at the very end, with nothing after it", () => { + const result = splitDescriptionByTechSteps("Faire cuire", [techStep("cook", 3, 6, 11)]); + expect(result).to.deep.equal([ + { text: "Faire ", techStep: null }, + { text: "cuire", techStep: { id: 3, key: "cook" } }, + ]); + }); + + it("handles several non-adjacent matches, preserving the plain text between them", () => { + const text = "Préchauffer la poêle, puis faire fondre le beurre"; + const result = splitDescriptionByTechSteps(text, [ + techStep("preheat", 4, 0, 11), + techStep("melt", 5, 27, 39), + ]); + expect(result.map((s) => s.text).join("")).to.equal(text); + expect(result.filter((s) => s.techStep !== null)).to.have.length(2); + expect(result[0]).to.deep.equal({ text: "Préchauffer", techStep: { id: 4, key: "preheat" } }); + }); + + it("re-sorts entries that aren't already in start order", () => { + const text = "Faire fondre le beurre puis préchauffer le four"; + // Passed in techStepId order, not text order — the function must sort + // by `start`, not trust the input order. + const result = splitDescriptionByTechSteps(text, [ + techStep("preheat", 4, 28, 39), + techStep("melt", 5, 0, 12), + ]); + const matches = result.filter((s) => s.techStep !== null); + expect(matches.map((s) => s.techStep?.key)).to.deep.equal(["melt", "preheat"]); + }); + + it("drops a match whose end is past the end of the description", () => { + const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 0, 999)]); + expect(result).to.deep.equal([{ text: "Cuire", techStep: null }]); + }); + + it("drops a match with a negative start", () => { + const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, -1, 5)]); + expect(result).to.deep.equal([{ text: "Cuire", techStep: null }]); + }); + + it("drops a match whose start isn't before its end", () => { + const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 3, 3)]); + expect(result).to.deep.equal([{ text: "Cuire", techStep: null }]); + }); + + it("drops a later match that overlaps one already accepted", () => { + // Two entries claiming overlapping ranges shouldn't happen in practice + // (the backend already resolves overlaps), but the splitter defends + // against it anyway rather than producing a garbled/duplicated slice. + const result = splitDescriptionByTechSteps("Cuire au four", [ + techStep("bake", 3, 0, 13), + techStep("cook", 2, 0, 5), + ]); + expect(result).to.deep.equal([{ text: "Cuire au four", techStep: { id: 3, key: "bake" } }]); + }); + + it("returns a single empty-ish segment for an empty description with no matches", () => { + expect(splitDescriptionByTechSteps("", [])).to.deep.equal([]); + }); +}); diff --git a/apps/web/cypress/e2e/recipes.cy.ts b/apps/web/cypress/e2e/recipes.cy.ts index 2bdb266..8b52664 100644 --- a/apps/web/cypress/e2e/recipes.cy.ts +++ b/apps/web/cypress/e2e/recipes.cy.ts @@ -64,8 +64,8 @@ const omeletteDetail = { }, ], steps: [ - { id: 1, description: "Battre les œufs.", picture: null, order: 1 }, - { id: 2, description: "Cuire à la poêle.", picture: null, order: 2 }, + { id: 1, description: "Battre les œufs.", picture: null, order: 1, techSteps: [] }, + { id: 2, description: "Cuire à la poêle.", picture: null, order: 2, techSteps: [] }, ], }; diff --git a/apps/web/cypress/e2e/recipes.feature b/apps/web/cypress/e2e/recipes.feature index 18a0ea4..bc0b5d1 100644 --- a/apps/web/cypress/e2e/recipes.feature +++ b/apps/web/cypress/e2e/recipes.feature @@ -19,6 +19,14 @@ Feature: Managing a recipe from the catalog And the favorite star should be marked as favorite And the recipe "Omelette" should be marked as favorite + Scenario: Highlights a detected technique in a step, with its name shown on focus + Given the recipe catalog contains "Omelette" + And recipe 2's detail is available + When I visit "/recettes/2" + Then I should see the highlighted technique "Cuire" + When I focus the highlighted technique "Cuire" + Then the tooltip should show "Cuire" + Scenario: Deletes a recipe after a two-step confirmation, then clears the selection Given the recipe catalog contains "Omelette" And recipe 2's detail is available diff --git a/apps/web/cypress/e2e/recipes.ts b/apps/web/cypress/e2e/recipes.ts index 275eb90..90f9554 100644 --- a/apps/web/cypress/e2e/recipes.ts +++ b/apps/web/cypress/e2e/recipes.ts @@ -34,8 +34,17 @@ const omeletteDetail = { }, ], steps: [ - { id: 1, description: "Battre les œufs.", picture: null, order: 1 }, - { id: 2, description: "Cuire à la poêle.", picture: null, order: 2 }, + { id: 1, description: "Battre les œufs.", picture: null, order: 1, techSteps: [] }, + { + id: 2, + description: "Cuire à la poêle.", + picture: null, + order: 2, + // "Cuire" -> the `cook` technique, matching real reference-seed-data.ts + // (`\bcui(re|sez|sant|sson)\b`) — "poêle" itself matches nothing + // (that's `panFry`'s "sauter", a different word). + techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }], + }, ], }; @@ -102,3 +111,15 @@ Then("the delete request should have been made", () => { Then("the URL should match the recipes list", () => { cy.url().should("match", /\/recettes\/?$/); }); + +Then("I should see the highlighted technique {string}", (text: string) => { + cy.contains(".step-tech-step", text).should("be.visible"); +}); + +When("I focus the highlighted technique {string}", (text: string) => { + cy.contains(".step-tech-step", text).focus(); +}); + +Then("the tooltip should show {string}", (label: string) => { + cy.get(".tooltip__bubble").contains(label).should("be.visible"); +}); diff --git a/apps/web/src/components/ui/Tooltip.tsx b/apps/web/src/components/ui/Tooltip.tsx new file mode 100644 index 0000000..c323850 --- /dev/null +++ b/apps/web/src/components/ui/Tooltip.tsx @@ -0,0 +1,34 @@ +import { type ReactElement, cloneElement, useId } from "react"; +import "./tooltip.scss"; + +/** + * App-wide tooltip primitive — CSS-only (no positioning library, same + * "let the browser/CSS do the work" philosophy as `Dialog.tsx`'s native + * ``): a `position: relative` wrapper around `children` (the + * trigger) plus a `role="tooltip"` bubble, shown via `:hover`/ + * `:focus-within` on the wrapper (see `tooltip.scss`) rather than JS state. + * + * `children` must be a single focusable element (e.g. the highlighted + * ` + + ); + })} +

+ ); +} diff --git a/apps/web/src/features/recipes/highlight-tech-steps.ts b/apps/web/src/features/recipes/highlight-tech-steps.ts new file mode 100644 index 0000000..735cd5a --- /dev/null +++ b/apps/web/src/features/recipes/highlight-tech-steps.ts @@ -0,0 +1,47 @@ +import type { StepTechStepView } from "@batch-cooking/shared"; + +/** + * One run of a step's `description` — either plain text, or the exact + * words that triggered a technique match (`techStep` set). What + * `StepDescription.tsx` renders: plain segments as-is, technique segments + * wrapped in a highlighted, tooltip-bearing ``. + */ +export interface DescriptionSegment { + text: string; + techStep: StepTechStepView["techStep"] | null; +} + +/** + * Splits `description` into an ordered sequence of plain/technique + * {@link DescriptionSegment}s using each `techSteps` entry's `start`/`end` + * (see `StepTechStepView`, resolved server-side by + * `tech-step-matcher.ts`'s `matchTechStepSpans`). + * + * `techSteps` is expected already sorted by `start` (the API returns it in + * `StepTechStep.order`, which *is* reading order — see that model's schema + * doc comment) but this re-sorts defensively rather than assuming it, and + * silently drops any entry whose bounds don't make sense against + * `description` (`start < 0`, `end > description.length`, `start >= end`, + * or overlapping a previously-accepted entry) — a malformed/out-of-date + * span degrades to "just don't highlight that one" rather than a garbled + * slice or a crash. + */ +export function splitDescriptionByTechSteps( + description: string, + techSteps: StepTechStepView[], +): DescriptionSegment[] { + const sorted = [...techSteps].sort((a, b) => a.start - b.start); + + const segments: DescriptionSegment[] = []; + let cursor = 0; + for (const { techStep, start, end } of sorted) { + if (start < 0 || end > description.length || start >= end || start < cursor) continue; + if (start > cursor) segments.push({ text: description.slice(cursor, start), techStep: null }); + segments.push({ text: description.slice(start, end), techStep }); + cursor = end; + } + if (cursor < description.length) { + segments.push({ text: description.slice(cursor), techStep: null }); + } + return segments; +} diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index a1dd7b3..4523b00 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -537,6 +537,34 @@ } } +// A step's detected-technique keyword (see StepDescription.tsx) — a real +//