feat(recipes): surligne les tech steps détectés dans les étapes, avec tooltip

Expose côté UI ce que tech-step-matcher.ts détecte déjà à la sauvegarde
(Step.techSteps) mais qui restait backend-only : dans le panneau détail
d'une recette, les mots exacts ayant déclenché une technique sont
surlignés, avec un tooltip (survol/focus clavier) donnant son nom.

- tech-step-matcher.ts : matchTechStepSpans(description, mappings) expose
  désormais {techStepId, start, end} en plus de la simple séquence d'ids
  (déjà calculé en interne, jusqu'ici jeté). matchTechSteps devient un
  wrapper fin dessus — aucun changement à ses ~12 tests existants ni à
  recipe-translation.ts.
- StepTechStep gagne start/end (nullable, pas de backfill — même leçon que
  l'incident de migration ingredient_unit_catalog : NOT NULL sans défaut
  sur une table déjà peuplée casse le déploiement). Une ligne pré-existante
  sans span est simplement omise de la réponse API plutôt que de fuiter un
  null, jusqu'à ce que la recette soit resauvegardée.
- recipe.service.ts : createRecipe/updateRecipe persistent start/end ;
  StepView expose techSteps: { techStep: {id,key}, start, end }[]. Le
  recalcul complet à chaque édition (ajout/modif/suppression d'étape) était
  déjà garanti par le delete-then-recreate existant d'updateRecipe — testé
  explicitement (nouveau test "recomputes techniques from scratch...").
- Frontend : StepDescription.tsx (découpe le texte via
  highlight-tech-steps.ts, pur et testé) remplace le <p> brut dans
  RecipeDetailPanel. Nouveau Tooltip.tsx (composants/ui, CSS pur, aucune
  lib externe — même esprit que Dialog.tsx) : un <button> (focusable
  nativement, pas de tabIndex sur un <mark> non interactif) affiche le nom
  de la technique (catalog.techSteps.<key>) au survol/focus.

Tests : matchTechStepSpans (spans corrects, chevauchement résolu),
recipe.test.ts (forme API + recalcul complet sur modif/ajout/suppression
d'étape, avec vérification que les anciennes lignes StepTechStep sont bien
supprimées), splitDescriptionByTechSteps (tri, bornes invalides ignorées,
chevauchement résiduel ignoré), scénario Cypress recipes.feature
(surlignage + tooltip au focus).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-20 14:46:15 +02:00
parent f58ac82a07
commit d0900ad7fd
17 changed files with 649 additions and 37 deletions

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "step_tech_step" ADD COLUMN "end" INTEGER,
ADD COLUMN "start" INTEGER;

View file

@ -672,13 +672,26 @@ model Step {
/// `melt`), which is why this replaced the original single nullable /// `melt`), which is why this replaced the original single nullable
/// `Step.techStepId` FK (per PR review feedback on the first version of /// `Step.techStepId` FK (per PR review feedback on the first version of
/// this feature). `order` is the position within *this step* (0-based, in /// 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 /// techniques in the description), not a global ordering across different
/// steps of the recipe (that's `Step.order`). /// 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 { model StepTechStep {
stepId Int @map("step_id") stepId Int @map("step_id")
techStepId Int @map("tech_step_id") techStepId Int @map("tech_step_id")
order Int order Int
start Int?
end Int?
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade) step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade) techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)

View file

@ -4,21 +4,23 @@ import { prisma } from "../db/prisma.js";
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe * Auto-detects which cooking techniques (`TechStep`) a free-text recipe
* step description corresponds to, using the static `TechStepMapping` * step description corresponds to, using the static `TechStepMapping`
* catalog (see `reference-seed-data.ts`'s `TECH_STEPS`) groundwork for a * catalog (see `reference-seed-data.ts`'s `TECH_STEPS`) groundwork for a
* future batch-cooking optimization algorithm, not surfaced in the recipe * future batch-cooking optimization algorithm, and (via `matchTechStepSpans`)
* UI yet (see `StepView` in `packages/shared`). * 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. * 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 * "Dans une poêle chaude, faire chauffer une noix de beurre" is both
* `preheat` and `melt`) `matchTechSteps` returns the whole *ordered * `preheat` and `melt`) both `matchTechSteps`/`matchTechStepSpans` return
* sequence* it finds, not a single winner, matching `Step.techSteps` * the whole *ordered sequence* they find, not a single winner, matching
* (schema.prisma's `StepTechStep`, an ordered join table). * `Step.techSteps` (schema.prisma's `StepTechStep`, an ordered join table).
* *
* `normalizeText`/`matchTechSteps` are pure (no DB access) so they can be * `normalizeText`/`matchTechStepSpans`/`matchTechSteps` are pure (no DB
* unit-tested in isolation (see `test/tech-step-matcher.test.ts`). * access) so they can be unit-tested in isolation (see
* `loadTechStepMappingRules` is the only DB-touching piece, kept separate * `test/tech-step-matcher.test.ts`). `loadTechStepMappingRules` is the only
* so callers (`recipe.service.ts`) fetch the whole mapping list once per * DB-touching piece, kept separate so callers (`recipe.service.ts`) fetch
* request and pass it to `matchTechSteps` per step, rather than querying * the whole mapping list once per request and pass it to
* once per step. * `matchTechStepSpans` per step, rather than querying once per step.
*/ */
/** One `TechStepMapping` row, trimmed to what {@link matchTechSteps} needs. */ /** 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(); 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 { interface MatchCandidate extends TechStepMappingRule {
start: number; start: number;
end: number; end: number;
@ -57,9 +59,23 @@ function overlaps(a: MatchCandidate, b: MatchCandidate): boolean {
return a.start < b.end && b.start < a.end; 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 * 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 * 1. Test every mapping against the normalized description; each one that
* matches becomes a candidate carrying *where* it matched (so * 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 * 4. Sort what's left by where it appears in the text the sequence
* reads in the same order as the instruction itself. * 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 * Pure takes `mappings` as a plain argument rather than querying Prisma
* itself, so it's testable without a database (see * itself, so it's testable without a database (see
* `loadTechStepMappingRules` for the DB-backed loader). `mappings` should * `loadTechStepMappingRules` for the DB-backed loader). `mappings` should
* already be filtered to the locale the caller cares about this function * 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. * 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 normalizedDescription = normalizeText(description);
const candidates: MatchCandidate[] = []; const candidates: MatchCandidate[] = [];
@ -123,7 +152,18 @@ export function matchTechSteps(description: string, mappings: TechStepMappingRul
// Step 4: reading order. // Step 4: reading order.
accepted.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId); 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);
} }
/** /**

View file

@ -8,12 +8,13 @@ import {
type RecipeSummaryView, type RecipeSummaryView,
type RecipeTab, type RecipeTab,
type RecipeView, type RecipeView,
type StepTechStepView,
type UnitView, type UnitView,
type UpdateRecipeInput, type UpdateRecipeInput,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js"; 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 // No user-language preference exists anywhere in the app yet (a single
// "fr" translation file, no locale field on User/UserProfile) — steps are // "fr" translation file, no locale field on User/UserProfile) — steps are
@ -36,7 +37,10 @@ function recipeInclude(viewerId: number) {
unit: true, unit: true,
}, },
}, },
steps: { orderBy: { order: "asc" } }, steps: {
orderBy: { order: "asc" },
include: { techSteps: { orderBy: { order: "asc" }, include: { techStep: true } } },
},
diets: { include: { diet: true } }, diets: { include: { diet: true } },
favoritedBy: { where: { userProfileId: viewerId } }, favoritedBy: { where: { userProfileId: viewerId } },
} satisfies Prisma.RecipeInclude; } 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}. */ /** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */
function toRecipeView(recipe: RecipeWithDetails): RecipeView { function toRecipeView(recipe: RecipeWithDetails): RecipeView {
const ingredients = recipe.ingredients.map((recipeIngredient) => ({ const ingredients = recipe.ingredients.map((recipeIngredient) => ({
@ -118,6 +144,7 @@ function toRecipeView(recipe: RecipeWithDetails): RecipeView {
description: step.description, description: step.description,
picture: step.picture, picture: step.picture,
order: step.order, order: step.order,
techSteps: toStepTechStepViews(step.techSteps),
})), })),
}; };
} }
@ -365,8 +392,10 @@ export async function createRecipe(
picture: step.picture ?? null, picture: step.picture ?? null,
order: index, order: index,
techSteps: { techSteps: {
create: matchTechSteps(step.description, techStepMappings).map((techStepId, order) => ({ create: matchTechStepSpans(step.description, techStepMappings).map((match, order) => ({
techStepId, techStepId: match.techStepId,
start: match.start,
end: match.end,
order, order,
})), })),
}, },
@ -429,9 +458,11 @@ export async function updateRecipe(
picture: step.picture ?? null, picture: step.picture ?? null,
order: index, order: index,
techSteps: { techSteps: {
create: matchTechSteps(step.description, techStepMappings).map( create: matchTechStepSpans(step.description, techStepMappings).map(
(techStepId, order) => ({ (match, order) => ({
techStepId, techStepId: match.techStepId,
start: match.start,
end: match.end,
order, order,
}), }),
), ),

View file

@ -377,19 +377,27 @@ describe("Recipes", () => {
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
const piece = await unitId("piece"); const piece = await unitId("piece");
const simmer = await techStepId("simmer"); const simmer = await techStepId("simmer");
const description = "Faire mijoter à feu doux pendant 30 minutes";
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Ragoût", name: "Ragoût",
portions: 4, portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description: "Faire mijoter à feu doux pendant 30 minutes" }], steps: [{ description }],
}); });
expect(res.status).to.equal(201); 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 } }); const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
expect(await stepTechStepIds(step.id)).to.deep.equal([simmer]); 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 () => { 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 piece = await unitId("piece");
const preheat = await techStepId("preheat"); const preheat = await techStepId("preheat");
const melt = await techStepId("melt"); const melt = await techStepId("melt");
const description = "Préchauffer la poêle, puis faire fondre le beurre";
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Poêlée", name: "Poêlée",
@ -473,12 +482,25 @@ describe("Recipes", () => {
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
// The case that motivated the sequence model: one instruction, two techniques. // 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); expect(res.status).to.equal(201);
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } }); const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
expect(await stepTechStepIds(step.id)).to.deep.equal([preheat, melt]); 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 () => { 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]); 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 () => { it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");

View file

@ -3,6 +3,7 @@ import { prisma } from "../src/db/prisma.js";
import { import {
type TechStepMappingRule, type TechStepMappingRule,
loadTechStepMappingRules, loadTechStepMappingRules,
matchTechStepSpans,
matchTechSteps, matchTechSteps,
normalizeText, normalizeText,
} from "../src/lib/tech-step-matcher.js"; } 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", () => { describe("loadTechStepMappingRules", () => {
beforeEach(async () => { beforeEach(async () => {
await resetDatabase(); await resetDatabase();

View file

@ -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([]);
});
});

View file

@ -64,8 +64,8 @@ const omeletteDetail = {
}, },
], ],
steps: [ steps: [
{ id: 1, description: "Battre les œufs.", picture: null, order: 1 }, { id: 1, description: "Battre les œufs.", picture: null, order: 1, techSteps: [] },
{ id: 2, description: "Cuire à la poêle.", picture: null, order: 2 }, { id: 2, description: "Cuire à la poêle.", picture: null, order: 2, techSteps: [] },
], ],
}; };

View file

@ -19,6 +19,14 @@ Feature: Managing a recipe from the catalog
And the favorite star should be marked as favorite And the favorite star should be marked as favorite
And the recipe "Omelette" 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 Scenario: Deletes a recipe after a two-step confirmation, then clears the selection
Given the recipe catalog contains "Omelette" Given the recipe catalog contains "Omelette"
And recipe 2's detail is available And recipe 2's detail is available

View file

@ -34,8 +34,17 @@ const omeletteDetail = {
}, },
], ],
steps: [ steps: [
{ id: 1, description: "Battre les œufs.", picture: null, order: 1 }, { id: 1, description: "Battre les œufs.", picture: null, order: 1, techSteps: [] },
{ id: 2, description: "Cuire à la poêle.", picture: null, order: 2 }, {
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", () => { Then("the URL should match the recipes list", () => {
cy.url().should("match", /\/recettes\/?$/); 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");
});

View file

@ -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
* `<dialog>`): 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
* `<button>` in `StepDescription.tsx`) this component clones
* it to attach `aria-describedby`, linking the trigger to the bubble's text
* for screen readers, the same way a native `title` attribute would be
* announced, but with real, styleable content instead of the UA tooltip.
*/
export function Tooltip({
content,
children,
}: {
content: string;
children: ReactElement<{ "aria-describedby"?: string }>;
}) {
const id = useId();
return (
<span className="tooltip">
{cloneElement(children, { "aria-describedby": id })}
<span role="tooltip" id={id} className="tooltip__bubble">
{content}
</span>
</span>
);
}

View file

@ -0,0 +1,53 @@
// Tooltip primitive see Tooltip.tsx. Shown via :hover/:focus-within on
// the wrapper, no JS positioning: a small bubble centered above the
// trigger, with a matching arrow. Colocated here rather than in
// global.scss, same convention as dialog.scss.
.tooltip {
position: relative;
display: inline;
}
.tooltip__bubble {
position: absolute;
bottom: calc(100% + var(--space-xs));
left: 50%;
z-index: 10;
width: max-content;
max-width: 16rem;
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-xs);
font-weight: 400;
line-height: 1.3;
color: var(--color-background);
background: var(--color-text);
border-radius: var(--radius-base);
box-shadow: var(--shadow-sm);
opacity: 0;
visibility: hidden;
transform: translate(-50%, 4px);
transition:
opacity 0.12s ease,
transform 0.12s ease,
visibility 0.12s;
pointer-events: none;
// Small arrow pointing down at the trigger.
&::after {
position: absolute;
top: 100%;
left: 50%;
width: 0.5rem;
height: 0.5rem;
content: "";
background: var(--color-text);
transform: translate(-50%, -50%) rotate(45deg);
}
}
.tooltip:hover .tooltip__bubble,
.tooltip:focus-within .tooltip__bubble {
opacity: 1;
visibility: visible;
transform: translate(-50%, 0);
}

View file

@ -6,6 +6,7 @@ import { ApiError, apiClient } from "../../api/client";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import { AllergenBadges } from "./AllergenBadges"; import { AllergenBadges } from "./AllergenBadges";
import { FavoriteStarButton } from "./FavoriteStarButton"; import { FavoriteStarButton } from "./FavoriteStarButton";
import { StepDescription } from "./StepDescription";
import "./recipes.scss"; import "./recipes.scss";
/** State {@link RecipeDetailPanel} renders — `"empty"` (no row selected yet) is distinct from `"not-found"` (a selected id that turned out invalid/inaccessible), each with its own message. */ /** State {@link RecipeDetailPanel} renders — `"empty"` (no row selected yet) is distinct from `"not-found"` (a selected id that turned out invalid/inaccessible), each with its own message. */
@ -129,7 +130,7 @@ export function RecipeDetailPanel({
{recipe.steps.map((step) => ( {recipe.steps.map((step) => (
<li key={step.id}> <li key={step.id}>
{step.picture && <img src={step.picture} alt="" />} {step.picture && <img src={step.picture} alt="" />}
<p>{step.description}</p> <StepDescription description={step.description} techSteps={step.techSteps} />
</li> </li>
))} ))}
</ol> </ol>

View file

@ -0,0 +1,52 @@
import type { StepTechStepView } from "@batch-cooking/shared";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "../../components/ui/Tooltip";
import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
/**
* A recipe step's description, with every detected technique's exact
* matched words highlighted and given a {@link Tooltip} naming the
* technique (e.g. hovering/focusing "hacher" in "Hacher les oignons" shows
* "Hacher") `RecipeDetailPanel`'s replacement for a bare `<p>{description}</p>`.
*
* `techStep.key` resolves its tooltip label through `catalog.techSteps.<key>`
* i18n, the same pattern every other reference catalog (diets, units, )
* uses for its display text.
*/
export function StepDescription({
description,
techSteps,
}: {
description: string;
techSteps: StepTechStepView[];
}) {
const { t } = useTranslation();
const segments = splitDescriptionByTechSteps(description, techSteps);
return (
<p>
{segments.map((segment, index) => {
// A segment's own text/techStep don't uniquely identify it (the
// same word can appear twice in one description) — index is the
// only thing that does, but this list is fully regenerated from
// `description`/`techSteps` on every render (never reordered or
// spliced in place), so using it as part of the key is safe here.
const key = `${index}-${segment.text}`;
if (!segment.techStep) return <Fragment key={key}>{segment.text}</Fragment>;
return (
<Tooltip key={key} content={t(`catalog.techSteps.${segment.techStep.key}`)}>
{/* A real <button>, not a <mark>, so it's natively focusable
(keyboard/screen-reader users can reach the tooltip) without
fighting the "non-interactive element" a11y lint a bare
tabIndex on <mark> would trip styled to read as inline
highlighted text, not as a button (see .step-tech-step). */}
<button type="button" className="step-tech-step">
{segment.text}
</button>
</Tooltip>
);
})}
</p>
);
}

View file

@ -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 `<mark>`.
*/
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;
}

View file

@ -537,6 +537,34 @@
} }
} }
// A step's detected-technique keyword (see StepDescription.tsx) — a real
// <button> (natively focusable, so the Tooltip it wraps is
// keyboard-reachable, not just hoverable see that component), reset here
// to read as inline highlighted text rather than a button: a subtle dotted
// underline + tinted background, same "primary-tinted" language as
// `.source-select__badge.is-official`, not a loud/distracting highlight
// since it sits inline within normal reading text.
.step-tech-step {
display: inline;
padding: 0 0.15em;
margin: 0;
font: inherit;
color: inherit;
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border: none;
border-radius: 0.2em;
text-decoration: underline dotted var(--color-primary);
text-decoration-thickness: 1px;
text-underline-offset: 0.15em;
cursor: help;
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--color-primary) 24%, transparent);
outline: none;
}
}
// --- Favorite star toggle (detail panel header) ----------------------------- // --- Favorite star toggle (detail panel header) -----------------------------
.favorite-star-button { .favorite-star-button {
position: absolute; position: absolute;

View file

@ -1,4 +1,4 @@
import type { AllergyView, DietView, IngredientView, UnitView } from "./reference.js"; import type { AllergyView, DietView, IngredientView, TechStepView, UnitView } from "./reference.js";
/** /**
* Who can *read* a recipe mirrors `RecipeVisibility` in schema.prisma. * Who can *read* a recipe mirrors `RecipeVisibility` in schema.prisma.
@ -25,17 +25,34 @@ export interface RecipeIngredientView {
} }
/** /**
* A single preparation step within a recipe, in `order`. Its detected * One detected technique within a {@link StepView}'s description, resolved
* technique sequence (`Step.techSteps`/`StepTechStep` in schema.prisma, * to its reference data (same "resolve at read time" treatment as
* auto-computed at save time via `tech-step-matcher.ts`) is deliberately * {@link RecipeIngredientView}'s `ingredient`/`unit`) alongside exactly
* not surfaced here groundwork for a future batch-cooking optimization * where in `description` it was matched (`[start, end)`, same convention as
* algorithm, not yet consumed by any UI. * `String.prototype.slice`) what the recipe detail view highlights, with
* `techStep.key` resolving a tooltip label through `catalog.techSteps.<key>`
* i18n, the same pattern as every other reference catalog.
*/
export interface StepTechStepView {
techStep: TechStepView;
start: number;
end: number;
}
/**
* A single preparation step within a recipe, in `order`. `techSteps` is its
* detected technique sequence (`Step.techSteps`/`StepTechStep` in
* schema.prisma, auto-computed at save time via `tech-step-matcher.ts`)
* empty if the description doesn't mention any known technique, or if it
* does but the match predates `StepTechStep.start`/`end` existing and the
* recipe hasn't been resaved since (see the schema doc comment).
*/ */
export interface StepView { export interface StepView {
id: number; id: number;
description: string; description: string;
picture: string | null; picture: string | null;
order: number; order: number;
techSteps: StepTechStepView[];
} }
/** /**