Merge pull request #43 from kyuno053/feat/highlight-tech-steps
feat(recipes): surligne les tech steps détectés dans les étapes, avec tooltip
This commit is contained in:
commit
9e5e950873
17 changed files with 653 additions and 37 deletions
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "step_tech_step" ADD COLUMN "end" INTEGER,
|
||||||
|
ADD COLUMN "start" INTEGER;
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
100
apps/web/cypress/component/highlight-tech-steps.cy.tsx
Normal file
100
apps/web/cypress/component/highlight-tech-steps.cy.tsx
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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: [] },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,19 @@ 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) => {
|
||||||
|
// The steps section sits below the panel's header/photo/description, off
|
||||||
|
// the fold of `.app-content`'s own scroll (see layout.cy.ts) — a bare
|
||||||
|
// `.should("be.visible")` doesn't auto-scroll, same fix as
|
||||||
|
// household-settings.feature's sources-section scenario.
|
||||||
|
cy.contains(".step-tech-step", text).scrollIntoView().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");
|
||||||
|
});
|
||||||
|
|
|
||||||
34
apps/web/src/components/ui/Tooltip.tsx
Normal file
34
apps/web/src/components/ui/Tooltip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/web/src/components/ui/tooltip.scss
Normal file
53
apps/web/src/components/ui/tooltip.scss
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
52
apps/web/src/features/recipes/StepDescription.tsx
Normal file
52
apps/web/src/features/recipes/StepDescription.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
apps/web/src/features/recipes/highlight-tech-steps.ts
Normal file
47
apps/web/src/features/recipes/highlight-tech-steps.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue