feat(recipes): permet de corriger la température d'un tech-step #29

Merged
kyuno merged 3 commits from feat/tech-step-temperature-correction into main 2026-09-05 08:06:05 +02:00
12 changed files with 1652 additions and 387 deletions

View file

@ -32,22 +32,24 @@ const correctionInclude = {
} satisfies Prisma.StepTechStepCorrectionInclude;
/**
* Loads `stepId`'s current `description` length (the only thing a
* correction needs from the step itself), or throws — `404 STEP_NOT_FOUND`
* if no such step exists, or if it exists but doesn't belong to `recipeId`
* (the route's own `:id`/`:stepId` nesting is meaningless otherwise — a
* request naming a real step under the wrong recipe should look identical
* to naming one that doesn't exist, same "don't leak which part was wrong"
* posture `assertRecipeVisible` already has for visibility). Otherwise
* whatever {@link assertRecipeVisible} throws (`404 RECIPE_NOT_FOUND`,
* never `403`) if the recipe exists but isn't visible to the viewer.
* Loads `stepId`'s current `description` (a correction needs its full text
* now, not just its length — see {@link submitTechStepCorrection}'s
* `raw` derivation for a manual temperature mention), or throws —
* `404 STEP_NOT_FOUND` if no such step exists, or if it exists but doesn't
* belong to `recipeId` (the route's own `:id`/`:stepId` nesting is
* meaningless otherwise — a request naming a real step under the wrong
* recipe should look identical to naming one that doesn't exist, same
* "don't leak which part was wrong" posture `assertRecipeVisible` already
* has for visibility). Otherwise whatever {@link assertRecipeVisible}
* throws (`404 RECIPE_NOT_FOUND`, never `403`) if the recipe exists but
* isn't visible to the viewer.
*/
async function loadVisibleStepOrThrow(
recipeId: number,
stepId: number,
viewerId: number,
viewerHouseId: number | null,
): Promise<{ id: number; descriptionLength: number }> {
): Promise<{ id: number; description: string }> {
try {
const step = await prisma.step.findUnique({
where: { id: stepId },
@ -57,7 +59,7 @@ async function loadVisibleStepOrThrow(
throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
}
await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
return { id: step.id, descriptionLength: step.description.length };
return { id: step.id, description: step.description };
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
@ -208,6 +210,21 @@ interface ManualUtensilMention {
start: number;
end: number;
}
/**
* Same as {@link ManualIngredientMention}, for a temperature — `raw` is
* already resolved by the caller ({@link submitTechStepCorrection}, which
* has the step's full `description` text to slice) rather than trusted
* from the client; see `manualStepTechStepTemperatureInputSchema`'s own
* doc comment (`packages/shared`) for why.
*/
interface ManualTemperatureMention {
celsius: number | null;
gasMark: number | null;
qualitative: "low" | "medium" | "high" | null;
raw: string;
start: number;
end: number;
}
/**
* Applies a correction's *effect* on `stepId`'s real `StepTechStep`
@ -233,16 +250,16 @@ interface ManualUtensilMention {
*
* `metadata`, when given (only ever alongside a real `correctedTechStepId`
* — enforced by `submitTechStepCorrectionSchema`, not re-checked here),
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil` row on
* this occurrence — `source: "auto"` (the classifier's own detection) and
* any earlier `"manual"` set alike — with the newly-submitted one. This is
* "le manuel remplace tout" (confirmed with the user): the resolved
* `order` this technique ends up at (whichever branch above produced it)
* is the same `techStepOrder` both metadata tables key on, so the same
* `deleteMany` + `createMany` pair below is correct whether this call just
* updated an existing row (which may already carry auto-detected
* metadata) or created a brand new one (nothing to delete yet — a no-op
* `deleteMany`, not a special case).
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil`/
* `StepTechStepTemperature` row on this occurrence — `source: "auto"` (the
* classifier's own detection) and any earlier `"manual"` set alike — with
* the newly-submitted one. This is "le manuel remplace tout" (confirmed
* with the user): the resolved `order` this technique ends up at
* (whichever branch above produced it) is the same `techStepOrder` all
* three metadata tables key on, so the same `deleteMany` + `createMany`
* pair below is correct whether this call just updated an existing row
* (which may already carry auto-detected metadata) or created a brand new
* one (nothing to delete yet — a no-op `deleteMany`, not a special case).
*
* Runs inside the same transaction {@link submitTechStepCorrection} uses
* for the audit-trail insert, so a request never leaves any of these
@ -255,7 +272,11 @@ async function applyManualCorrection(
span: { start: number; end: number },
previousTechStepId: number | null,
correctedTechStepId: number | null,
metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] },
metadata?: {
ingredients: ManualIngredientMention[];
utensils: ManualUtensilMention[];
temperatures: ManualTemperatureMention[];
},
): Promise<void> {
const existing = await tx.stepTechStep.findMany({ where: { stepId } });
@ -329,6 +350,22 @@ async function applyManualCorrection(
})),
});
}
await tx.stepTechStepTemperature.deleteMany({ where: { stepId, techStepOrder: order } });
if (metadata.temperatures.length > 0) {
await tx.stepTechStepTemperature.createMany({
data: metadata.temperatures.map((temperature) => ({
stepId,
techStepOrder: order,
celsius: temperature.celsius,
gasMark: temperature.gasMark,
qualitative: temperature.qualitative,
raw: temperature.raw,
start: temperature.start,
end: temperature.end,
source: "manual",
})),
});
}
}
} else if (target) {
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
@ -366,10 +403,11 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
* `start`/`end` (the correction's own span, or any of
* `input.ingredients`/`input.utensils`' own spans) fall outside the
* step's current `description` (it may have been edited since the user
* last saw it). `404 TECH_STEP_NOT_FOUND`/`404 INGREDIENT_NOT_FOUND`/
* `404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` if any referenced id
* `input.ingredients`/`input.utensils`/`input.temperatures`' own spans)
* fall outside the step's current `description` (it may have been
* edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`/
* `404 INGREDIENT_NOT_FOUND`/`404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND`
* if any referenced id
* doesn't exist.
*/
export async function submitTechStepCorrection(
@ -381,18 +419,20 @@ export async function submitTechStepCorrection(
): Promise<SubmitTechStepCorrectionResult> {
try {
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
const descriptionLength = step.description.length;
const spans = [
{ start: input.start, end: input.end },
...(input.ingredients ?? []),
...(input.utensils ?? []),
...(input.temperatures ?? []),
];
for (const span of spans) {
if (span.start >= step.descriptionLength || span.end > step.descriptionLength) {
if (span.start >= descriptionLength || span.end > descriptionLength) {
throw new HttpError(
400,
ErrorCode.INVALID_CORRECTION_SPAN,
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${descriptionLength})`,
);
}
}
@ -428,7 +468,9 @@ export async function submitTechStepCorrection(
{ start: input.start, end: input.end },
input.previousTechStepId ?? null,
input.correctedTechStepId ?? null,
input.ingredients === undefined && input.utensils === undefined
input.ingredients === undefined &&
input.utensils === undefined &&
input.temperatures === undefined
? undefined
: {
ingredients: (input.ingredients ?? []).map((ingredient) => ({
@ -443,6 +485,16 @@ export async function submitTechStepCorrection(
start: utensil.start,
end: utensil.end,
})),
// `raw` is derived here (not sent by the client) — see
// `ManualTemperatureMention`'s own doc comment.
temperatures: (input.temperatures ?? []).map((temperature) => ({
celsius: temperature.celsius ?? null,
gasMark: temperature.gasMark ?? null,
qualitative: temperature.qualitative ?? null,
raw: step.description.slice(temperature.start, temperature.end),
start: temperature.start,
end: temperature.end,
})),
},
);

View file

@ -518,6 +518,191 @@ describe("Recipe tech-step corrections", () => {
});
});
describe("POST /recipes/:id/steps/:stepId/corrections — temperature metadata", () => {
it("attaches a manually-selected celsius temperature, deriving raw from the step's own description", async () => {
const { agent, profileId } = await signup();
const description = "Préchauffez à 180°C.";
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
const preheatId = await techStepId("preheat");
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 0,
end: 11,
correctedTechStepId: preheatId,
temperatures: [{ celsius: 180, start: 14, end: 19 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps).to.deep.equal([
{
techStep: { id: preheatId, key: "preheat" },
start: 0,
end: 11,
source: "manual",
ingredients: [],
utensils: [],
temperatures: [
{
celsius: 180,
gasMark: null,
qualitative: null,
// Not sent by the client — derived server-side from the span
// against the step's real description (see
// `ManualTemperatureMention`'s doc comment).
raw: "180°C",
start: 14,
end: 19,
source: "manual",
},
],
},
]);
});
it("attaches a manually-selected qualitative temperature", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: simmerId,
temperatures: [{ qualitative: "low", start: 0, end: 5 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps[0].temperatures).to.deep.equal([
{
celsius: null,
gasMark: null,
qualitative: "low",
raw: "Faire",
start: 0,
end: 5,
source: "manual",
},
]);
});
it("attaches a manually-selected gas-mark temperature", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: simmerId,
temperatures: [{ gasMark: 6, start: 0, end: 5 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps[0].temperatures[0]).to.deep.equal({
celsius: null,
gasMark: 6,
qualitative: null,
raw: "Faire",
start: 0,
end: 5,
source: "manual",
});
});
it("rejects a temperature with more than one of celsius/gasMark/qualitative set with 400 VALIDATION_ERROR", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: await techStepId("simmer"),
temperatures: [{ celsius: 180, qualitative: "high", start: 0, end: 5 }],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a temperature with none of celsius/gasMark/qualitative set with 400 VALIDATION_ERROR", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: await techStepId("simmer"),
temperatures: [{ start: 0, end: 5 }],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("replaces both auto-detected and previously-manual temperatures on the same occurrence — never accumulates", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const boilId = await techStepId("boil");
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
// Simulate an auto-detected temperature already sitting on the
// occurrence, exactly as `toStepTechStepCreateInputs` would have
// written one at save time.
await prisma.stepTechStepTemperature.create({
data: {
stepId,
techStepOrder: 0,
celsius: 100,
raw: "100°C",
start: 0,
end: 5,
source: "auto",
},
});
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
previousTechStepId: simmerId,
correctedTechStepId: boilId,
temperatures: [{ gasMark: 6, start: 0, end: 5 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps).to.have.length(1);
expect(res.body.techSteps[0].temperatures).to.deep.equal([
{
celsius: null,
gasMark: 6,
qualitative: null,
raw: "Faire",
start: 0,
end: 5,
source: "manual",
},
]);
});
it("rejects a temperature span past the end of the step's description with 400 INVALID_CORRECTION_SPAN", async () => {
const { agent, profileId } = await signup();
const description = "Court.";
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 0,
end: description.length,
correctedTechStepId: await techStepId("simmer"),
temperatures: [{ celsius: 180, start: 0, end: description.length + 10 }],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN);
});
});
describe("GET /recipes/:id/steps/:stepId/corrections", () => {
it("returns every correction submitted for the step, most recent first", async () => {
const { agent, profileId } = await signup();

View file

@ -16,6 +16,9 @@ const butter = { id: 10, key: "butter" };
const pan = { id: 20, key: "pan" };
const gram = { id: 30, key: "gram" };
/** Long enough that `range={start:0,end:5}` ("Cuire") and a resolved metadata span at `[20,26)` ("Beurre") both fall within it — see {@link Harness}'s fixed `onRequestSpan` stub. */
const DESCRIPTION = "Cuire un peu de beurre.";
/**
* A real `StepDescription` resolves `onRequestSpan` into a fresh
* `resolvedMetadataSpan` via an actual browser text selection — out of
@ -29,18 +32,20 @@ function Harness({
previousTechStepId = null,
existingIngredients = [],
existingUtensils = [],
existingTemperatures = [],
onClose = () => {},
onSubmitted = () => {},
}: Partial<{
previousTechStepId: number | null;
existingIngredients: unknown[];
existingUtensils: unknown[];
existingTemperatures: unknown[];
onClose: () => void;
onSubmitted: (result: unknown) => void;
}>) {
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
nonce: number;
kind: "ingredient" | "utensil";
kind: "ingredient" | "utensil" | "temperature";
range: { start: number; end: number };
text: string;
} | null>(null);
@ -52,13 +57,16 @@ function Harness({
<TechStepCorrectionPopover
recipeId={2}
stepId={2}
description={DESCRIPTION}
selectedText="Cuire"
range={{ start: 0, end: 5 }}
previousTechStepId={previousTechStepId}
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView props — precise typing isn't the point here.
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView/TemperatureView props — precise typing isn't the point here.
existingIngredients={existingIngredients as any}
// biome-ignore lint/suspicious/noExplicitAny: see above.
existingUtensils={existingUtensils as any}
// biome-ignore lint/suspicious/noExplicitAny: see above.
existingTemperatures={existingTemperatures as any}
resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={(kind) =>
setResolvedMetadataSpan({
@ -90,34 +98,40 @@ describe("TechStepCorrectionPopover", () => {
);
});
it("shows the selected text, the technique catalog (searchable) and the metadata sections all together", () => {
it("shows the selected text, the technique picker (searchable) and all three metadata sections together", () => {
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no
// separate "pick, then metadata reveals itself" step, both render at
// once, and the technique catalog goes through the same searchable
// `CatalogSearchPicker` as the ingredient/utensil sub-flows (a plain
// unfiltered list of the real ~74-entry catalog isn't browsable).
// separate "pick, then metadata reveals itself" step, all four
// sections render at once, and the technique catalog goes through the
// same searchable `CatalogSearchPicker` as the ingredient/utensil
// sub-flows (a plain unfiltered list of the real ~74-entry catalog
// isn't browsable) — visible by default since nothing's picked yet.
cy.get(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
).should("have.length", 2);
cy.contains("h4", "Ingrédients").should("be.visible");
cy.contains("h4", "Ustensiles").should("be.visible");
cy.contains(".tech-step-correction-popover__section-title", "Ingrédients").should("be.visible");
cy.contains(".tech-step-correction-popover__section-title", "Ustensiles").should("be.visible");
cy.contains(".tech-step-correction-popover__section-title", "Température").should("be.visible");
cy.contains("button", "Valider").should("be.visible");
});
it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => {
cy.mount(<Harness previousTechStepId={null} />);
cy.wait("@getTechSteps");
cy.get(".tech-step-correction-popover__remove").should("not.exist");
cy.get(".tech-step-correction-popover__remove-match").should("not.exist");
cy.contains(".tech-step-correction-popover__chosen-technique", "Aucune technique sélectionnée");
cy.mount(<Harness previousTechStepId={cook.id} />);
cy.wait("@getTechSteps");
cy.get(".tech-step-correction-popover__remove").should("exist");
cy.get(".tech-step-correction-popover__remove-match").should("exist");
cy.contains(".tech-step-correction-popover__chosen-technique", "Cuire");
// The picker itself hides behind "Changer" once something's already
// picked (see TechStepCorrectionPopover's own doc comment) — reopened
// here just to confirm it still marks the current pick correctly.
cy.contains("button", "Changer").click();
cy.contains(".catalog-search-picker__list button", "Cuire").should(
"have.class",
"catalog-search-picker__item--selected",
@ -149,7 +163,7 @@ describe("TechStepCorrectionPopover", () => {
cy.contains("button", "Valider").should("not.be.disabled");
});
it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => {
it("submits the selected technique (no metadata touched) with ingredients/utensils/temperatures omitted from the request", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,
body: {
@ -200,14 +214,16 @@ describe("TechStepCorrectionPopover", () => {
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "+ Ajouter un ingrédient").click();
cy.contains("button", "Ajouter un ingrédient").click();
cy.contains(".catalog-search-picker button", "Beurre").click();
cy.get('input[type="number"]').type("50");
cy.get("select").select(String(gram.id));
cy.contains("button", "Ajouter").click();
cy.contains(".tech-step-correction-popover__add-form button", "Ajouter").click();
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").should("be.visible");
cy.contains(".tech-step-correction-popover__row", "Beurre")
.should("contain.text", "Beurre")
.and("contain.text", "50 g");
cy.contains("button", "Valider").click();
cy.wait("@submitCorrection")
@ -221,28 +237,85 @@ describe("TechStepCorrectionPopover", () => {
{ ingredientId: butter.id, quantity: 50, unitId: gram.id, start: 20, end: 26 },
],
utensils: [],
temperatures: [],
});
});
it("pre-seeds existing ingredients/utensils, removable via their own chip", () => {
it("adds a temperature via the span-selection flow and its 3-way mode switch, included in the submitted request", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,
body: {
id: 1,
start: 0,
end: 5,
previousTechStep: null,
correctedTechStep: simmer,
createdAt: new Date().toISOString(),
},
}).as("submitCorrection");
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "Ajouter une température").click();
// Defaults to the qualitative mode — switching to °C swaps the input
// shown, never both at once (mutually exclusive, see
// `manualStepTechStepTemperatureInputSchema`'s own doc comment,
// packages/shared).
cy.contains(".tech-step-correction-popover__mode-switch button", "°C").click();
cy.get('.tech-step-correction-popover__value-line input[type="number"]').type("180");
cy.contains(".tech-step-correction-popover__add-form button", "Ajouter").click();
cy.contains(".tech-step-correction-popover__row", "180")
.should("contain.text", "°C")
.and("contain.text", "Beurre"); // the (stubbed) source quote
cy.contains("button", "Valider").click();
cy.wait("@submitCorrection")
.its("request.body")
.should("deep.equal", {
start: 0,
end: 5,
previousTechStepId: null,
correctedTechStepId: simmer.id,
ingredients: [],
utensils: [],
temperatures: [{ celsius: 180, gasMark: null, qualitative: null, start: 20, end: 26 }],
});
});
it("pre-seeds existing ingredients/utensils/temperatures, each removable via its own row", () => {
cy.mount(
<Harness
previousTechStepId={cook.id}
existingIngredients={[
{ ingredient: butter, quantity: 50, unit: gram, start: 0, end: 6, source: "auto" },
]}
existingUtensils={[{ utensil: pan, start: 14, end: 23, source: "auto" }]}
existingUtensils={[{ utensil: pan, start: 9, end: 15, source: "auto" }]}
existingTemperatures={[
{ celsius: null, gasMark: null, qualitative: "low", start: 16, end: 22, source: "auto" },
]}
/>,
);
// An existing match starts pre-selected on itself (see
// TechStepCorrectionPopover's own doc comment) — the metadata sections,
// pre-seeded from `existingIngredients`/`existingUtensils`, are visible
// immediately, no need to re-pick "Cuire" from a list first.
// TechStepCorrectionPopover's own doc comment) — the metadata
// sections, pre-seeded from `existingIngredients`/`existingUtensils`/
// `existingTemperatures`, are visible immediately, no need to re-pick
// "Cuire" from a list first.
cy.wait("@getTechSteps");
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click();
cy.contains(".tech-step-correction-popover__chip", "Beurre").should("not.exist");
cy.contains(".tech-step-correction-popover__row", "Beurre").should("be.visible");
cy.contains(".tech-step-correction-popover__row", "Feu doux").should("be.visible");
cy.contains(".tech-step-correction-popover__row", "Beurre")
.find(".tech-step-correction-popover__row-remove")
.click();
cy.contains(".tech-step-correction-popover__row", "Beurre").should("not.exist");
});
it("shows an error message and stays open when the submission fails", () => {

View file

@ -45,6 +45,21 @@ Feature: Managing a recipe from the catalog
When I focus the highlighted technique "Cuire"
Then the tooltip should show "Mijoter (correction manuelle)"
Scenario: Clicking a highlighted temperature reopens its technique's correction popover
# Same master-detail room requirement as recipes.cy.ts's own equivalent
# test — Cypress's default 1000×660 doesn't leave the detail panel
# (with the popover open below its step) enough height to show
# everything without its own scroll.
Given the viewport is 1600 by 900
And the recipe catalog contains "Gratin"
And recipe 4's detail is available
And the tech steps reference list includes preheat
When I visit "/recettes/4"
Then I should see the highlighted temperature "feu moyen"
When I click the highlighted temperature "feu moyen"
Then I should see the technique correction options
And the chosen technique should be "Préchauffer"
Scenario: Deletes a recipe after a two-step confirmation, then clears the selection
Given the recipe catalog contains "Omelette"
And recipe 2's detail is available

View file

@ -48,6 +48,50 @@ const omeletteDetail = {
],
};
/**
* A separate, self-contained fixture for the "clicking a temperature
* highlight" scenario — kept apart from `omeletteDetail` above rather than
* adding a temperature to its "Cuire" occurrence, so this doesn't entangle
* with that fixture's own "Corrects a detected technique" scenario (which
* relabels the same occurrence without touching metadata, and must keep
* meaning "nothing here changed").
*/
const gratinDetail = {
id: 4,
name: "Gratin",
description: null,
picture: null,
portions: 4,
authorId: 1,
visibility: "PERSONAL",
allergens: [],
diets: [],
isFavorite: false,
ingredients: [],
steps: [
{
id: 10,
// "Préchauffez" = [0, 11), "à feu moyen" = [20, 31) — counted char by
// char (accented/multi-byte characters here are each still one JS
// string index, same convention `start`/`end` use everywhere else).
description: "Préchauffez le four à feu moyen.",
picture: null,
order: 1,
techSteps: [
{
techStep: { id: 9, key: "preheat" },
start: 0,
end: 11,
source: "auto",
temperatures: [
{ celsius: null, gasMark: null, qualitative: "medium", start: 20, end: 31 },
],
},
],
},
],
};
Given("the disliked ingredients list is empty", () => {
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
});
@ -68,6 +112,23 @@ Given("deleting recipe 2 will succeed", () => {
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
});
Given("recipe 4's detail is available", () => {
cy.intercept("GET", "**/recipes/4", { statusCode: 200, body: gratinDetail });
});
// TechStepCorrectionPopover fetches the full technique catalog on mount
// (to resolve the chosen technique's own label) — a dedicated fixture
// naming just `preheat` (`gratinDetail`'s own technique), kept separate
// from "the tech steps reference list has options" above (that one's
// `cook`/`simmer` pair is purpose-built for the *other* scenario's
// relabel, not a generic catalog stand-in).
Given("the tech steps reference list includes preheat", () => {
cy.intercept("GET", "**/reference/tech-steps", {
statusCode: 200,
body: [{ id: 9, key: "preheat" }],
});
});
// Step 2 is `omeletteDetail`'s "Cuire à la poêle." step, whose only
// existing match is `cook` (id 1) — see that fixture above. The response
// mirrors `SubmitTechStepCorrectionResult` (packages/shared): the audit
@ -96,6 +157,7 @@ Given('correcting step 2\'s "Cuire" match will succeed', () => {
source: "manual",
ingredients: [],
utensils: [],
temperatures: [],
},
],
},
@ -110,6 +172,23 @@ Then("I should see the technique correction options", () => {
cy.get(".tech-step-correction-popover").should("be.visible");
});
Then("I should see the highlighted temperature {string}", (text: string) => {
// Same "off the fold, needs an explicit scroll" reasoning as
// common.steps.ts's "I should see the highlighted technique" — a bare
// `.should("be.visible")` doesn't auto-scroll.
cy.contains(".step-temperature", text).scrollIntoView().should("be.visible");
});
// Temperature is corrected through its owning technique's own popover, not
// on its own — see `StepDescription.tsx`'s `findTechStepForTemperature`.
When("I click the highlighted temperature {string}", (text: string) => {
cy.contains(".step-temperature", text).click();
});
Then("the chosen technique should be {string}", (label: string) => {
cy.contains(".tech-step-correction-popover__chosen-technique", label).should("be.visible");
});
// Picking a technique only *selects* it — it takes a separate "Valider"
// click to actually submit (room was made for attaching ingredient/utensil
// metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc
@ -118,8 +197,13 @@ Then("I should see the technique correction options", () => {
// technique catalog is picked via the same searchable `CatalogSearchPicker`
// the ingredient/utensil sub-flows use, scoped to
// `__technique-section` since that same search-and-pick component is
// reused inside this popover for more than just techniques.
// reused inside this popover for more than just techniques. This step is
// only ever used to *relabel* an existing match (this feature's one
// scenario using it) — the picker hides behind "Changer" in that case
// (nothing to decide once a technique's already confirmed correct, see
// TechStepCorrectionPopover's own doc comment), so it's reopened first.
When("I choose {string} as the correct technique", (label: string) => {
cy.contains(".tech-step-correction-popover__technique-change", "Changer").click();
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
label,

View file

@ -673,7 +673,10 @@
text-decoration: underline dotted var(--color-primary);
text-decoration-thickness: 1px;
text-underline-offset: 0.15em;
cursor: help;
// A real clickable trigger (opens `TechStepCorrectionPopover`), not just
// a hover-for-a-tooltip target — `pointer`, not `help`, same reasoning
// now shared by `.step-temperature` below.
cursor: pointer;
&:hover,
&:focus-visible {
@ -702,17 +705,31 @@
// `--color-primary`, so a temperature reads as its own kind of metadata at
// a glance. `--low`/`--medium`/`--high` only vary the underline weight —
// the qualitative scale is a nuance, not three different colors.
//
// Renders as a `<button>` (not a `<span>`) in editable mode when it has an
// owning technique occurrence to reopen (see `StepDescription.tsx`'s
// `findTechStepForTemperature`) — same button-reset-to-inline-text
// treatment as `.step-tech-step` above (`font`/`color`/`margin`/`border`),
// so the two read identically whether they're a `<button>` or a plain
// `<span>` (read-only, or no owner to reopen).
.step-temperature {
display: inline;
padding: 0 0.15em;
margin: 0;
font: inherit;
color: inherit;
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
border: none;
border-radius: 0.2em;
text-decoration: underline dotted var(--color-accent);
text-decoration-thickness: 1px;
text-underline-offset: 0.15em;
cursor: help;
cursor: pointer;
&:hover {
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--color-accent) 22%, transparent);
outline: none;
}
&--high {
@ -746,6 +763,9 @@
// in the document flow right below the step's own description instead of
// floating anchored at the selection.
.tech-step-correction-popover {
display: flex;
flex-direction: column;
gap: var(--space-md);
margin-top: var(--space-xs);
padding: var(--space-md);
background: var(--color-surface-alt);
@ -754,30 +774,94 @@
&__selection {
font-weight: 600;
margin: 0 0 var(--space-sm);
margin: 0;
}
// Technique picker + Ingrédients/Ustensiles render together as one
// screen now (see `TechStepCorrectionPopover.tsx`'s own doc comment) —
// this section just needs its own small header row, the actual picker
// is `.catalog-search-picker` (below), reused as-is from the ingredient/
// utensil sub-flows.
&__technique-section {
h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
&__technique-header {
// One shared shape for every section (Technique/Ingrédients/Ustensiles/
// Température) — see `TechStepCorrectionPopover.tsx`'s own doc comment
// for why all four render together, always, rather than one at a time.
&__section {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-direction: column;
gap: var(--space-sm);
}
&__remove {
&__section-header {
display: flex;
align-items: center;
gap: var(--space-xs);
padding-bottom: var(--space-xs);
border-bottom: 1px solid var(--color-border);
svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}
}
// Same "kicker" label language as `.cooking-phase__recipe-group-title`
// (cooking-session-page.scss) — a small bold uppercase eyebrow, not a
// full heading, since four of these stack in one popover.
&__section-title {
font-family: var(--font-display);
font-weight: 700;
font-size: var(--font-size-sm);
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted);
}
&__section-count {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
// One icon color per category — set on the icon itself (not the whole
// section) so the title text next to it stays neutral/muted.
&__technique-section &__section-header svg {
color: var(--color-primary);
}
&__ingredients-section &__section-header svg,
&__utensils-section &__section-header svg {
color: var(--color-tag);
}
&__temperature-section &__section-header svg {
color: var(--color-accent);
}
&__chosen-technique {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
}
&__technique-pill {
display: inline-flex;
align-items: center;
background: var(--color-primary);
color: var(--color-surface);
padding: var(--space-xs) var(--space-md);
border-radius: var(--radius-pill);
font-family: var(--font-display);
font-weight: 700;
font-size: var(--font-size-sm);
}
&__technique-change {
background: none;
border: none;
color: var(--color-primary);
text-decoration: underline;
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
padding: 0;
}
&__remove-match {
padding: 0.2rem 0.5rem;
font-size: var(--font-size-sm);
color: var(--color-error);
@ -792,7 +876,8 @@
}
}
&__cancel {
&__cancel,
&__cancel-mini {
background: none;
border: none;
color: var(--color-text-muted);
@ -802,113 +887,262 @@
font-size: var(--font-size-sm);
}
// Shown in place of the technique list/metadata sections while
// `StepDescription` is waiting on a second text selection (see
// `TechStepCorrectionPopover.tsx`'s own doc comment) — same styling
// intent as `.recipe-detail-panel__tech-step-hint`, a small muted aside.
// Shown in place of a section's own "+ Ajouter…" button while
// `StepDescription` is waiting on a second text selection for *that*
// section (see `TechStepCorrectionPopover.tsx`'s own doc comment) — same
// styling intent as `.recipe-detail-panel__tech-step-hint`, a small
// muted aside.
&__hint {
margin: 0 0 var(--space-sm);
margin: 0;
color: var(--color-text-muted);
font-style: italic;
font-size: var(--font-size-sm);
}
&__rows {
display: flex;
flex-direction: column;
gap: var(--space-xs);
list-style: none;
margin: 0;
padding: 0;
}
// One attached ingredient/utensil/temperature — a small card of its own
// (not a chip pill anymore: showing the source quote alongside the name
// needs more room than a pill comfortably gives).
&__row {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
background: var(--color-surface);
border-radius: var(--radius-base);
box-shadow: var(--shadow-sm);
padding: var(--space-sm);
}
&__row-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&__row-name {
font-weight: 600;
}
&__row-quote {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-style: italic;
}
&__span-picker {
&__row-badge {
flex-shrink: 0;
align-self: center;
background: var(--color-surface-alt);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
padding: 2px var(--space-sm);
border-radius: var(--radius-pill);
}
&__row-remove {
flex-shrink: 0;
border: none;
background: none;
color: var(--color-text-muted);
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-base);
cursor: pointer;
svg {
width: 16px;
height: 16px;
}
&:hover:not(:disabled) {
color: var(--color-error);
background: var(--color-surface-alt);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// "+ Ajouter…" — a dashed ghost row, not a plain text link: at-a-glance
// an "add a new item" affordance, same weight as the rows it adds to.
&__add-row {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
border: 1.5px dashed var(--color-border);
border-radius: var(--radius-base);
padding: var(--space-sm);
color: var(--color-text-muted);
background: none;
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
svg {
width: 16px;
height: 16px;
}
&:hover:not(:disabled) {
border-color: var(--color-accent);
color: var(--color-accent);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// A section's own inline "adding a new mention" sub-form — replaces just
// that section's `__add-row` button, every other section (and the
// technique picker) stays visible and untouched alongside it.
&__add-form {
display: flex;
flex-direction: column;
gap: var(--space-sm);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
padding: var(--space-sm);
}
&__quantity-line {
&__add-form-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-sm);
}
&__quantity-line,
&__value-line {
display: flex;
align-items: center;
gap: var(--space-xs);
input[type="number"] {
width: 5rem;
}
}
&__confirm {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
// "Aucune technique sélectionnée."/"Technique retenue : X" — no button
// here anymore (re-picking happens directly through the search picker
// right below, see `TechStepCorrectionPopover.tsx`'s doc comment on the
// merged editor), just a small status line.
&__chosen-technique {
margin: 0 0 var(--space-xs);
font-weight: 600;
}
&__metadata-section {
margin-top: var(--space-sm);
h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// "+ Ajouter…" button — deliberately a plain text-link style, not
// another pill button (`.catalog-search-picker__list button`) — this
// is a secondary action inside an already-open popover, not a
// top-level choice competing with the chips above it.
> button {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
padding: 0;
font-size: var(--font-size-sm);
}
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
list-style: none;
margin: 0 0 var(--space-xs);
padding: 0;
}
&__chip {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 0.3rem 0.6rem;
font-size: var(--font-size-sm);
background: var(--color-surface);
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
}
}
&__value-unit {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
// Température's 3-way mode switch (°C / Thermostat / Qualitatif) — a
// segmented control, same connected-pill language as the qualitative
// sub-choice below, in the accent color (Vermillion, the "heat" accent —
// same as `.step-temperature`) so the active mode reads at a glance.
&__mode-switch {
display: flex;
gap: 2px;
background: var(--color-surface-alt);
border-radius: var(--radius-pill);
padding: 2px;
button {
background: none;
flex: 1;
border: none;
background: none;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-pill);
font-size: var(--font-size-sm);
font-weight: 700;
color: var(--color-text-muted);
cursor: pointer;
padding: 0;
line-height: 1;
&:hover:not(:disabled) {
color: var(--color-error);
&.active {
background: var(--color-accent);
color: var(--color-surface);
}
}
}
&__qual-switch {
display: flex;
gap: var(--space-xs);
button {
flex: 1;
border: 1.5px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-base);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
&.active {
border-color: var(--color-accent);
background: var(--color-accent);
color: var(--color-surface);
}
}
}
&__confirm-mini {
background: var(--color-accent);
color: var(--color-surface);
border: none;
border-radius: var(--radius-base);
font-size: var(--font-size-sm);
font-weight: 700;
padding: var(--space-xs) var(--space-md);
cursor: pointer;
&:hover {
background: var(--color-accent-hover);
}
}
&__actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-md);
padding-top: var(--space-xs);
}
&__confirm-button {
align-self: flex-start;
padding: 0.4rem 1rem;
font-size: var(--font-size-sm);
padding: var(--space-sm) var(--space-lg);
font-family: var(--font-display);
font-weight: 700;
letter-spacing: 0.02em;
color: var(--color-surface);
background: var(--color-primary);
border: none;
border-radius: var(--radius-md);
border-radius: var(--radius-base);
cursor: pointer;
&:hover:not(:disabled) {
background: var(--color-primary-hover);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;

View file

@ -1,4 +1,8 @@
import type { StepTechStepView, SubmitTechStepCorrectionResult } from "@batch-cooking/shared";
import type {
StepTechStepTemperatureView,
StepTechStepView,
SubmitTechStepCorrectionResult,
} from "@batch-cooking/shared";
import { Fragment, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "../../../components/ui/Tooltip";
@ -7,6 +11,27 @@ import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
import { temperatureLabel } from "./temperature-label";
import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
/**
* Finds which of `techSteps`' occurrences a given temperature mention
* belongs to — the read-only highlight flattens every occurrence's
* `temperatures` together (see this file's own `temperatures` above),
* losing that association, so a click handler that needs to reopen "the
* technique this temperature is attached to" re-derives it by matching
* span. `undefined` when nothing matches (shouldn't happen for a real
* persisted temperature, but degrades to "not clickable" rather than a
* crash — same posture as `use-text-selection.ts`'s `resolveOffset`).
*/
function findTechStepForTemperature(
techSteps: StepTechStepView[],
temperature: StepTechStepTemperatureView,
): StepTechStepView | undefined {
return techSteps.find((techStep) =>
(techStep.temperatures ?? []).some(
(candidate) => candidate.start === temperature.start && candidate.end === temperature.end,
),
);
}
/**
* A recipe step's description, with every detected technique's exact
* matched words highlighted and given a {@link Tooltip} naming the
@ -32,10 +57,15 @@ import { type TextSelectionRange, useTextSelection } from "./use-text-selection"
* color, so the two are visually distinguishable at a glance rather than
* only via the tooltip text.
*
* `editable` (off by default) additionally lets the viewer select text or
* click an existing highlight to open a {@link TechStepCorrectionPopover} —
* see `use-text-selection.ts` for how a browser selection is translated
* into an absolute `[start, end)` span. When `!editable`, every segment
* `editable` (off by default) additionally lets the viewer select text, or
* click an existing technique or temperature highlight, to open a
* {@link TechStepCorrectionPopover} — a temperature highlight reopens
* whichever technique occurrence it's attached to (see
* `findTechStepForTemperature` above), since temperature is corrected
* through that same popover, not on its own (same as ingredients/
* utensils). See `use-text-selection.ts` for how a browser selection is
* translated into an absolute `[start, end)` span. When `!editable`, every
* segment
* renders exactly as before (no extra wrapping elements, no `data-offset`,
* no click handlers) — this mode is purely additive, not a rewrite of the
* read-only rendering.
@ -90,18 +120,18 @@ export function StepDescription({
} | null>(null);
// Routes the *next* text selection to the open `TechStepCorrectionPopover`
// (as an ingredient/utensil mention span) instead of opening a brand-new
// correction — set when that popover calls `onRequestSpan`, cleared once
// (as an ingredient/utensil/temperature mention span) instead of opening
// a brand-new correction — set when that popover calls `onRequestSpan`, cleared once
// `handleMouseUp` resolves the selection below. See
// `TechStepCorrectionPopover.tsx`'s own doc comment for why this can live
// entirely alongside the still-visible, still-selectable description
// rather than needing the popover itself to move/hide.
const [pendingSpanRequest, setPendingSpanRequest] = useState<"ingredient" | "utensil" | null>(
null,
);
const [pendingSpanRequest, setPendingSpanRequest] = useState<
"ingredient" | "utensil" | "temperature" | null
>(null);
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
nonce: number;
kind: "ingredient" | "utensil";
kind: "ingredient" | "utensil" | "temperature";
range: TextSelectionRange;
text: string;
} | null>(null);
@ -185,23 +215,59 @@ export function StepDescription({
if (segment.temperature) {
// A detected temperature mention ("180°C", "feu doux") — its
// own subtle highlight + a Tooltip with the normalized value.
// Not correctable (no popover), same as utensils; still gets a
// `data-offset` in editable mode so a text selection spanning
// it resolves correctly.
// In editable mode, clicking it reopens the correction popover
// for whichever technique occurrence it's attached to (found by
// span, see `findTechStepForTemperature` — temperature is a
// per-clause metadata like ingredients/utensils, corrected
// through that technique's own popover, not on its own) — same
// `data-offset` treatment either way, so a text selection
// spanning it still resolves correctly.
const label = temperatureLabel(segment.temperature, t);
const modifier = segment.temperature.qualitative ?? "value";
const owner = editable
? findTechStepForTemperature(liveTechSteps, segment.temperature)
: undefined;
const className = `step-temperature step-temperature--${modifier}`;
if (owner === undefined || owner.start === null || owner.end === null) {
// Read-only, or (for a persisted temperature this shouldn't
// happen, see `findTechStepForTemperature`'s own doc comment)
// no owning occurrence with a real span to reopen — degrades
// to the same non-interactive span as before rather than a
// dead click.
return (
<Tooltip key={key} content={label}>
<span
className={`step-temperature step-temperature--${modifier}`}
data-offset={editable ? start : undefined}
>
<span className={className} data-offset={editable ? start : undefined}>
{segment.text}
</span>
</Tooltip>
);
}
const ownerStart = owner.start;
const ownerEnd = owner.end;
return (
<Tooltip key={key} content={label}>
<button
type="button"
className={className}
data-offset={start}
onClick={() => {
setPendingSpanRequest(null);
setResolvedMetadataSpan(null);
setActiveCorrection({
range: { start: ownerStart, end: ownerEnd },
selectedText: description.slice(ownerStart, ownerEnd),
previousTechStepId: owner.techStep.id,
});
}}
>
{segment.text}
</button>
</Tooltip>
);
}
if (!segment.techStep || !segment.isKeyword) {
// Context-only or plain run — rendered as plain text in
// read-only mode, same as before this component supported
@ -263,11 +329,13 @@ export function StepDescription({
<TechStepCorrectionPopover
recipeId={recipeId}
stepId={stepId}
description={description}
range={activeCorrection.range}
selectedText={activeCorrection.selectedText}
previousTechStepId={activeCorrection.previousTechStepId}
existingIngredients={activeStepTechStep?.ingredients ?? []}
existingUtensils={activeStepTechStep?.utensils ?? []}
existingTemperatures={activeStepTechStep?.temperatures ?? []}
resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={setPendingSpanRequest}
onClose={closeActiveCorrection}

View file

@ -2,46 +2,255 @@ import {
ErrorCode,
type IngredientView,
type StepTechStepIngredientView,
type StepTechStepTemperatureView,
type StepTechStepUtensilView,
type SubmitTechStepCorrectionResult,
type TechStepView,
type UnitView,
type UtensilView,
} from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../../api/client";
import { errorMessageService } from "../../../services/error-message.service";
import { ingredientLabel } from "../ingredients/ingredient-label";
import { CatalogSearchPicker } from "./CatalogSearchPicker";
import { temperatureLabel } from "./temperature-label";
import type { TextSelectionRange } from "./use-text-selection";
/** One ingredient the viewer has attached (or is about to submit) — the trimmed-down shape `POST .../corrections`'s `ingredients[]` expects, kept separately from `StepTechStepIngredientView` since a pending one has no resolved `IngredientView`/`UnitView` to carry yet, only ids. */
/** Which of the three metadata categories a span-selection sub-flow is currently for — see `onRequestSpan`/`resolvedMetadataSpan` below, and `StepDescription.tsx`'s own doc comment for the round-trip this drives. */
type MetadataKind = "ingredient" | "utensil" | "temperature";
/**
* One ingredient the viewer has attached (or is about to submit) — the
* trimmed-down shape `POST .../corrections`'s `ingredients[]` expects, kept
* separately from `StepTechStepIngredientView` since a pending one has no
* resolved `IngredientView`/`UnitView` to carry yet, only ids. `quote` is
* purely for this popover's own row display (the exact description text at
* `[start, end)`, resolved once at seed/add time) — never sent over the
* wire, the server already knows its own text (see {@link confirm}).
*/
interface PendingIngredient {
ingredientId: number;
quantity: number | null;
unitId: number | null;
start: number;
end: number;
quote: string;
}
/** Same as {@link PendingIngredient}, for a utensil (no quantity/unit — nothing to measure). */
interface PendingUtensil {
utensilId: number;
start: number;
end: number;
quote: string;
}
/**
* Same as {@link PendingIngredient}, for a temperature — mutually-exclusive
* `celsius`/`gasMark`/`qualitative`, same convention as
* `StepTechStepTemperatureView`/`manualStepTechStepTemperatureInputSchema`
* (`packages/shared`). No `raw` here either: the server derives it from the
* step's own description at submit time, see that schema's own doc comment
* for why a client-supplied copy isn't trusted.
*/
interface PendingTemperature {
celsius: number | null;
gasMark: number | null;
qualitative: "low" | "medium" | "high" | null;
start: number;
end: number;
quote: string;
}
function toPendingIngredient(view: StepTechStepIngredientView): PendingIngredient {
function toPendingIngredient(
view: StepTechStepIngredientView,
description: string,
): PendingIngredient {
return {
ingredientId: view.ingredient.id,
quantity: view.quantity,
unitId: view.unit?.id ?? null,
start: view.start,
end: view.end,
quote: description.slice(view.start, view.end),
};
}
function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
return { utensilId: view.utensil.id, start: view.start, end: view.end };
function toPendingUtensil(view: StepTechStepUtensilView, description: string): PendingUtensil {
return {
utensilId: view.utensil.id,
start: view.start,
end: view.end,
quote: description.slice(view.start, view.end),
};
}
function toPendingTemperature(
view: StepTechStepTemperatureView,
description: string,
): PendingTemperature {
return {
celsius: view.celsius,
gasMark: view.gasMark,
qualitative: view.qualitative,
start: view.start,
end: view.end,
quote: description.slice(view.start, view.end),
};
}
// --- Icons ------------------------------------------------------------------
// Stroke-based, 24×24, matching the `lucide-react` line-icon language the
// rest of the app draws its icons from (see `layouts/nav-icons.tsx`'s own
// doc comment) — hand-drawn here rather than pulled from that shared barrel
// since these four are only ever used inside this one component, not worth
// promoting to an app-wide icon.
function TagIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M11 3H5a2 2 0 0 0-2 2v6l9.6 9.6a2 2 0 0 0 2.8 0l6-6a2 2 0 0 0 0-2.8L11 3Z" />
<circle cx="7.5" cy="7.5" r="1.2" />
</svg>
);
}
function LeafIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z" />
<path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12" />
</svg>
);
}
function PanIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="10" cy="14" r="6" />
<line x1="15.5" y1="9.5" x2="21" y2="4" />
</svg>
);
}
function ThermometerIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M10 13.5V4a2 2 0 1 1 4 0v9.5a4 4 0 1 1-4 0Z" />
<line x1="12" y1="8" x2="14" y2="8" />
</svg>
);
}
function PlusIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
}
function RemoveIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<line x1="6" y1="6" x2="18" y2="18" />
<line x1="18" y1="6" x2="6" y2="18" />
</svg>
);
}
/** A metadata section's icon + uppercase label + item count — Technique/Ingrédients/Ustensiles/Température all share this same header shape. */
function SectionHeader({ icon, title, count }: { icon: ReactNode; title: string; count?: number }) {
return (
<div className="tech-step-correction-popover__section-header">
{icon}
<span className="tech-step-correction-popover__section-title">
{title}
{count !== undefined && (
<span className="tech-step-correction-popover__section-count"> · {count}</span>
)}
</span>
</div>
);
}
/** One attached ingredient/utensil/temperature — name/value, the exact description text it was resolved from, an optional trailing badge (an ingredient's quantity), and a remove button. Ingrédients/Ustensiles/Température all render their pending list through this same row shape. */
function MetadataRow({
label,
quote,
badge,
onRemove,
removeLabel,
disabled,
}: {
label: string;
quote: string;
badge?: string;
onRemove: () => void;
removeLabel: string;
disabled: boolean;
}) {
return (
<li className="tech-step-correction-popover__row">
<div className="tech-step-correction-popover__row-body">
<span className="tech-step-correction-popover__row-name">{label}</span>
<span className="tech-step-correction-popover__row-quote">« {quote} »</span>
</div>
{badge !== undefined && (
<span className="tech-step-correction-popover__row-badge">{badge}</span>
)}
<button
type="button"
className="tech-step-correction-popover__row-remove"
onClick={onRemove}
disabled={disabled}
title={removeLabel}
aria-label={removeLabel}
>
<RemoveIcon />
</button>
</li>
);
}
/**
@ -57,37 +266,43 @@ function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
* position — simpler and more robust than tracking a caret-anchored
* position across scroll/resize, at the cost of a little visual distance
* from the selected text itself. That placement matters beyond cosmetics
* here: it's *why* the "attach an ingredient/utensil" flow below can ask
* the viewer to select a second span of text without closing this popover
* first — the description stays fully visible and selectable the whole
* time, nothing overlays it.
* here: it's *why* the "attach an ingredient/utensil/temperature" flow
* below can ask the viewer to select a second span of text without closing
* this popover first — the description stays fully visible and selectable
* the whole time, nothing overlays it.
*
* **One merged editor, not a wizard**: picking a technique
* **One merged editor, not a wizard**: the technique picker
* (`CatalogSearchPicker`, searchable — the reference catalog is ~74
* entries, an unfiltered flat list wasn't browsable) and editing its
* Ingrédients/Ustensiles metadata render together on the same screen,
* always — there's no separate "pick, then a metadata step reveals
* itself" sequence to go through, and no dead end where metadata is
* technically attachable but not visible until some other action happens
* first. A single "Valider" submits everything at once; disabled until a
* entries, an unfiltered flat list wasn't browsable) and its Ingrédients/
* Ustensiles/Température metadata all render on the same screen, always —
* there's no separate "pick, then a metadata step reveals itself" sequence,
* and adding one metadata mention never hides the other sections or the
* technique picker while it's in progress (each section manages its own
* "adding" sub-form independently, see `activeSpan`/`awaitingSpanFor`
* below). A single "Valider" submits everything at once; disabled until a
* technique is actually selected (there's nothing to attach metadata to
* otherwise). **Removing** a match (`submit(null)`) stays its own
* immediate action next to the picker — nothing to attach when removing.
* The technique picker itself hides behind "Changer" once a technique is
* already picked (there's nothing to decide once it's confirmed correct) —
* shown by default only while nothing's picked yet.
*
* The two metadata sections are pre-seeded from `existingIngredients`/
* `existingUtensils` (whatever's already attached to this occurrence, auto-
* or manually-sourced — `[]` for a brand-new technique) and editable via
* add/remove — see `metadataTouched` below for why what's *displayed* here
* isn't automatically what gets *submitted*.
* The three metadata sections are pre-seeded from `existingIngredients`/
* `existingUtensils`/`existingTemperatures` (whatever's already attached to
* this occurrence, auto- or manually-sourced — `[]` for a brand-new
* technique) and editable via add/remove — see `metadataTouched` below for
* why what's *displayed* here isn't automatically what gets *submitted*.
*/
export function TechStepCorrectionPopover({
recipeId,
stepId,
description,
selectedText,
range,
previousTechStepId,
existingIngredients,
existingUtensils,
existingTemperatures,
resolvedMetadataSpan,
onRequestSpan,
onClose,
@ -95,14 +310,17 @@ export function TechStepCorrectionPopover({
}: {
recipeId: number;
stepId: number;
/** The step's full, original text — needed to show each attached ingredient/utensil/temperature's own source quote (`description.slice(start, end)`), not just the technique's own `selectedText`. */
description: string;
/** The selected span's own text — shown so the user confirms what they're tagging before picking a technique. */
selectedText: string;
range: TextSelectionRange;
/** Set when correcting an already-detected match (opened from clicking its highlight) rather than a fresh selection — passed through as-is on submit, and offers a "remove" option `null` doesn't. */
previousTechStepId: number | null;
/** Whatever ingredients/utensils already sit on this occurrence (both `"auto"` and `"manual"` sourced) — `[]` for a brand-new technique, nothing to pre-seed. */
/** Whatever ingredients/utensils/temperatures already sit on this occurrence (both `"auto"` and `"manual"` sourced) — `[]` for a brand-new technique, nothing to pre-seed. */
existingIngredients: StepTechStepIngredientView[];
existingUtensils: StepTechStepUtensilView[];
existingTemperatures: StepTechStepTemperatureView[];
/**
* A text span `StepDescription` just resolved on this popover's behalf,
* after a call to `onRequestSpan` below — `null` until then. Identified
@ -112,26 +330,23 @@ export function TechStepCorrectionPopover({
*/
resolvedMetadataSpan: {
nonce: number;
kind: "ingredient" | "utensil";
kind: MetadataKind;
range: TextSelectionRange;
text: string;
} | null;
/** Tells `StepDescription` "the next text selection in the description is for an ingredient/utensil mention, not a new technique correction" — see this component's own doc comment. */
onRequestSpan: (kind: "ingredient" | "utensil") => void;
/** Tells `StepDescription` "the next text selection in the description is for an ingredient/utensil/temperature mention, not a new technique correction" — see this component's own doc comment. */
onRequestSpan: (kind: MetadataKind) => void;
onClose: () => void;
onSubmitted: (result: SubmitTechStepCorrectionResult) => void;
}) {
const { t } = useTranslation();
const popoverRef = useRef<HTMLDivElement>(null);
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
// Opening this popover on an *already-detected* match (`previousTechStepId
// !== null`, i.e. the user clicked an existing highlight rather than
// selecting fresh text) starts pre-selected on that same technique, its
// name shown next to the picker right away — since the picker and the
// metadata sections render together regardless (see this component's own
// doc comment), this just saves re-picking the technique that's already
// correct before its metadata becomes editable.
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
// The technique picker hides behind "Changer" once something's already
// picked (see this component's own doc comment) — starts open only when
// there's nothing to show yet (a brand-new selection).
const [isChangingTechnique, setIsChangingTechnique] = useState(previousTechStepId === null);
const [catalogs, setCatalogs] = useState<{
ingredients: IngredientView[];
units: UnitView[];
@ -139,24 +354,28 @@ export function TechStepCorrectionPopover({
} | null>(null);
const [pendingIngredients, setPendingIngredients] = useState<PendingIngredient[]>(() =>
existingIngredients.map(toPendingIngredient),
existingIngredients.map((view) => toPendingIngredient(view, description)),
);
const [pendingUtensils, setPendingUtensils] = useState<PendingUtensil[]>(() =>
existingUtensils.map(toPendingUtensil),
existingUtensils.map((view) => toPendingUtensil(view, description)),
);
// Flips true the moment the viewer adds/removes a pending entry — never
// from the initial seeding above. `submit()` below only includes
// `ingredients`/`utensils` in the request when this is true, so
// relabeling/confirming a technique without ever opening either section
// leaves existing metadata completely alone server-side (see
// `submitTechStepCorrectionSchema`'s own doc comment, `packages/shared`,
// for why an *omitted* field — not an empty array — is what "don't
// touch it" means over the wire).
const [pendingTemperatures, setPendingTemperatures] = useState<PendingTemperature[]>(() =>
existingTemperatures.map((view) => toPendingTemperature(view, description)),
);
// Flips true the moment the viewer adds/removes a pending entry in *any*
// of the three metadata sections — never from the initial seeding above.
// `confirm()` below only includes ingredients/utensils/temperatures in
// the request when this is true, so relabeling/confirming a technique
// without ever touching a metadata section leaves existing metadata
// completely alone server-side (see `submitTechStepCorrectionSchema`'s
// own doc comment, `packages/shared`, for why an *omitted* field — not
// an empty array — is what "don't touch it" means over the wire). The
// three travel as one bundle: touching any one resends all three.
const [metadataTouched, setMetadataTouched] = useState(false);
const [awaitingSpanFor, setAwaitingSpanFor] = useState<"ingredient" | "utensil" | null>(null);
const [awaitingSpanFor, setAwaitingSpanFor] = useState<MetadataKind | null>(null);
const [activeSpan, setActiveSpan] = useState<{
kind: "ingredient" | "utensil";
kind: MetadataKind;
range: TextSelectionRange;
text: string;
} | null>(null);
@ -167,6 +386,14 @@ export function TechStepCorrectionPopover({
const [pickedIngredientId, setPickedIngredientId] = useState<number | null>(null);
const [spanQuantity, setSpanQuantity] = useState("");
const [spanUnitId, setSpanUnitId] = useState<number | null>(null);
// Only meaningful while `activeSpan?.kind === "temperature"` — which of
// the three mutually-exclusive representations the mini-form currently
// shows an input for (see `manualStepTechStepTemperatureInputSchema`'s
// own doc comment, `packages/shared`, for why only one at a time).
const [tempMode, setTempMode] = useState<"celsius" | "gasMark" | "qualitative">("qualitative");
const [tempCelsiusValue, setTempCelsiusValue] = useState("");
const [tempGasMarkValue, setTempGasMarkValue] = useState("");
const [tempQualChoice, setTempQualChoice] = useState<"low" | "medium" | "high">("low");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -186,8 +413,8 @@ export function TechStepCorrectionPopover({
};
}, []);
// Fetched unconditionally on mount — the Ingrédients/Ustensiles sections
// render alongside the technique picker from the start (see this
// Fetched unconditionally on mount — the Ingrédients/Ustensiles/Température
// sections render alongside the technique picker from the start (see this
// component's own doc comment on the merged editor), so there's no later
// point to defer this to anymore.
useEffect(() => {
@ -207,7 +434,7 @@ export function TechStepCorrectionPopover({
// Consumes a span `StepDescription` just resolved on this popover's
// behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the
// matching sub-picker and clears the "awaiting a selection" hint.
// matching section's sub-form and clears the "awaiting a selection" hint.
useEffect(() => {
if (resolvedMetadataSpan === null) return;
setActiveSpan({
@ -219,6 +446,10 @@ export function TechStepCorrectionPopover({
setPickedIngredientId(null);
setSpanQuantity("");
setSpanUnitId(null);
setTempMode("qualitative");
setTempCelsiusValue("");
setTempGasMarkValue("");
setTempQualChoice("low");
// Depends on the whole object, not just `.nonce` — `StepDescription`
// only ever calls its setter with a brand-new object (never mutates
// one in place), so reference equality alone already gives this the
@ -265,7 +496,13 @@ export function TechStepCorrectionPopover({
end: range.end,
previousTechStepId,
correctedTechStepId: selectedTechStepId,
...(metadataTouched ? { ingredients: pendingIngredients, utensils: pendingUtensils } : {}),
...(metadataTouched
? {
ingredients: pendingIngredients.map(({ quote: _quote, ...rest }) => rest),
utensils: pendingUtensils.map(({ quote: _quote, ...rest }) => rest),
temperatures: pendingTemperatures.map(({ quote: _quote, ...rest }) => rest),
}
: {}),
});
onSubmitted(result);
onClose();
@ -276,7 +513,7 @@ export function TechStepCorrectionPopover({
}
}
function requestSpan(kind: "ingredient" | "utensil") {
function requestSpan(kind: MetadataKind) {
setAwaitingSpanFor(kind);
onRequestSpan(kind);
}
@ -300,6 +537,7 @@ export function TechStepCorrectionPopover({
unitId: spanUnitId,
start: activeSpan.range.start,
end: activeSpan.range.end,
quote: activeSpan.text,
},
]);
setMetadataTouched(true);
@ -311,7 +549,33 @@ export function TechStepCorrectionPopover({
if (activeSpan === null) return;
setPendingUtensils((prev) => [
...prev,
{ utensilId, start: activeSpan.range.start, end: activeSpan.range.end },
{
utensilId,
start: activeSpan.range.start,
end: activeSpan.range.end,
quote: activeSpan.text,
},
]);
setMetadataTouched(true);
setActiveSpan(null);
}
function confirmTemperatureSpan() {
if (activeSpan === null) return;
const value =
tempMode === "celsius"
? { celsius: Number(tempCelsiusValue) || null, gasMark: null, qualitative: null }
: tempMode === "gasMark"
? { celsius: null, gasMark: Number(tempGasMarkValue) || null, qualitative: null }
: { celsius: null, gasMark: null, qualitative: tempQualChoice };
setPendingTemperatures((prev) => [
...prev,
{
...value,
start: activeSpan.range.start,
end: activeSpan.range.end,
quote: activeSpan.text,
},
]);
setMetadataTouched(true);
setActiveSpan(null);
@ -325,10 +589,18 @@ export function TechStepCorrectionPopover({
setPendingUtensils((prev) => prev.filter((_, i) => i !== index));
setMetadataTouched(true);
}
function removeTemperature(index: number) {
setPendingTemperatures((prev) => prev.filter((_, i) => i !== index));
setMetadataTouched(true);
}
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));
const currentTechniqueLabel =
selectedTechStepId !== null
? t(`catalog.techSteps.${techSteps?.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`)
: null;
return (
<div className="tech-step-correction-popover" ref={popoverRef}>
@ -336,17 +608,100 @@ export function TechStepCorrectionPopover({
{t("recipes.techStepCorrection.selectionLabel", { text: selectedText })}
</p>
{awaitingSpanFor !== null ? (
{techSteps === null ? (
<p>{t("recipes.loading")}</p>
) : (
<>
<section className="tech-step-correction-popover__section tech-step-correction-popover__technique-section">
<SectionHeader
icon={<TagIcon />}
title={t("recipes.techStepCorrection.techniqueSection")}
/>
<div className="tech-step-correction-popover__chosen-technique">
{currentTechniqueLabel !== null ? (
<span className="tech-step-correction-popover__technique-pill">
{currentTechniqueLabel}
</span>
) : (
t("recipes.techStepCorrection.noTechniqueSelected")
)}
{currentTechniqueLabel !== null && !isChangingTechnique && (
<button
type="button"
className="tech-step-correction-popover__technique-change"
onClick={() => setIsChangingTechnique(true)}
>
{t("recipes.techStepCorrection.changeTechnique")}
</button>
)}
{previousTechStepId !== null && (
<button
type="button"
disabled={isSubmitting}
onClick={removeMatch}
className="tech-step-correction-popover__remove-match"
>
{t("recipes.techStepCorrection.removeMatch")}
</button>
)}
</div>
{(currentTechniqueLabel === null || isChangingTechnique) && (
<CatalogSearchPicker
items={techSteps.map((techStep) => ({
id: techStep.id,
label: t(`catalog.techSteps.${techStep.key}`),
}))}
selectedId={selectedTechStepId ?? undefined}
onSelect={(id) => {
setSelectedTechStepId(id);
setIsChangingTechnique(false);
}}
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
/>
)}
</section>
<section className="tech-step-correction-popover__section tech-step-correction-popover__ingredients-section">
<SectionHeader
icon={<LeafIcon />}
title={t("recipes.techStepCorrection.ingredientsSection")}
count={pendingIngredients.length}
/>
{pendingIngredients.length > 0 && (
<ul className="tech-step-correction-popover__rows">
{pendingIngredients.map((ingredient, index) => {
const view = ingredientById.get(ingredient.ingredientId);
const unit =
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
return (
<MetadataRow
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
key={index}
label={view ? ingredientLabel(view, t) : "…"}
quote={ingredient.quote}
badge={
ingredient.quantity !== null
? `${ingredient.quantity}${unit ? ` ${t(`catalog.units.${unit.key}`)}` : ""}`
: undefined
}
onRemove={() => removeIngredient(index)}
removeLabel={t("recipes.techStepCorrection.removeIngredient")}
disabled={isSubmitting}
/>
);
})}
</ul>
)}
{awaitingSpanFor === "ingredient" ? (
<p className="tech-step-correction-popover__hint">
{t("recipes.techStepCorrection.selectSpanHint")}
</p>
) : activeSpan !== null ? (
<div className="tech-step-correction-popover__span-picker">
<p className="tech-step-correction-popover__selection">
{t("recipes.techStepCorrection.selectionLabel", { text: activeSpan.text })}
</p>
{activeSpan.kind === "ingredient" ? (
pickedIngredientId === null ? (
) : activeSpan?.kind === "ingredient" ? (
<div className="tech-step-correction-popover__add-form">
{pickedIngredientId === null ? (
<CatalogSearchPicker
items={(catalogs?.ingredients ?? []).map((ingredient) => ({
id: ingredient.id,
@ -368,7 +723,9 @@ export function TechStepCorrectionPopover({
/>
<select
value={spanUnitId ?? ""}
onChange={(e) => setSpanUnitId(e.target.value ? Number(e.target.value) : null)}
onChange={(e) =>
setSpanUnitId(e.target.value ? Number(e.target.value) : null)
}
aria-label={t("recipes.form.unitLabel")}
>
<option value="">{t("recipes.form.unitPlaceholder")}</option>
@ -378,12 +735,66 @@ export function TechStepCorrectionPopover({
</option>
))}
</select>
<button type="button" onClick={confirmIngredientSpan}>
<button
type="button"
className="tech-step-correction-popover__confirm-mini"
onClick={confirmIngredientSpan}
>
{t("recipes.techStepCorrection.addToList")}
</button>
</div>
)
)}
<button
type="button"
className="tech-step-correction-popover__cancel-mini"
onClick={cancelSpanSelection}
>
{t("recipes.techStepCorrection.cancelSpanSelection")}
</button>
</div>
) : (
<button
type="button"
className="tech-step-correction-popover__add-row"
onClick={() => requestSpan("ingredient")}
disabled={isSubmitting}
>
<PlusIcon />
{t("recipes.techStepCorrection.addIngredient")}
</button>
)}
</section>
<section className="tech-step-correction-popover__section tech-step-correction-popover__utensils-section">
<SectionHeader
icon={<PanIcon />}
title={t("recipes.techStepCorrection.utensilsSection")}
count={pendingUtensils.length}
/>
{pendingUtensils.length > 0 && (
<ul className="tech-step-correction-popover__rows">
{pendingUtensils.map((utensil, index) => {
const view = utensilById.get(utensil.utensilId);
return (
<MetadataRow
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient row list above.
key={index}
label={view ? t(`catalog.utensils.${view.key}`) : "…"}
quote={utensil.quote}
onRemove={() => removeUtensil(index)}
removeLabel={t("recipes.techStepCorrection.removeUtensil")}
disabled={isSubmitting}
/>
);
})}
</ul>
)}
{awaitingSpanFor === "utensil" ? (
<p className="tech-step-correction-popover__hint">
{t("recipes.techStepCorrection.selectSpanHint")}
</p>
) : activeSpan?.kind === "utensil" ? (
<div className="tech-step-correction-popover__add-form">
<CatalogSearchPicker
items={(catalogs?.utensils ?? []).map((utensil) => ({
id: utensil.id,
@ -393,111 +804,169 @@ export function TechStepCorrectionPopover({
placeholder={t("recipes.techStepCorrection.searchUtensilPlaceholder")}
emptyLabel={t("recipes.techStepCorrection.noUtensilFound")}
/>
)}
<button
type="button"
className="tech-step-correction-popover__cancel"
className="tech-step-correction-popover__cancel-mini"
onClick={cancelSpanSelection}
>
{t("recipes.techStepCorrection.cancelSpanSelection")}
</button>
</div>
) : techSteps === null ? (
<p>{t("recipes.loading")}</p>
) : (
<div className="tech-step-correction-popover__confirm">
<section className="tech-step-correction-popover__technique-section">
<div className="tech-step-correction-popover__technique-header">
<h4>{t("recipes.techStepCorrection.techniqueSection")}</h4>
{previousTechStepId !== null && (
<button
type="button"
disabled={isSubmitting}
onClick={removeMatch}
className="tech-step-correction-popover__remove"
>
{t("recipes.techStepCorrection.removeMatch")}
</button>
)}
</div>
<p className="tech-step-correction-popover__chosen-technique">
{selectedTechStepId !== null
? t("recipes.techStepCorrection.currentTechnique", {
technique: t(
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
),
})
: t("recipes.techStepCorrection.noTechniqueSelected")}
</p>
<CatalogSearchPicker
items={techSteps.map((techStep) => ({
id: techStep.id,
label: t(`catalog.techSteps.${techStep.key}`),
}))}
selectedId={selectedTechStepId ?? undefined}
onSelect={setSelectedTechStepId}
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
/>
</section>
<section className="tech-step-correction-popover__metadata-section">
<h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4>
<ul className="tech-step-correction-popover__chips">
{pendingIngredients.map((ingredient, index) => {
const view = ingredientById.get(ingredient.ingredientId);
const unit =
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
const label = view ? ingredientLabel(view, t) : "…";
return (
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
<li key={index} className="tech-step-correction-popover__chip">
{ingredient.quantity !== null ? `${ingredient.quantity} ` : ""}
{unit ? `${t(`catalog.units.${unit.key}`)} ` : ""}
{label}
<button
type="button"
onClick={() => removeIngredient(index)}
title={t("recipes.techStepCorrection.removeIngredient")}
className="tech-step-correction-popover__add-row"
onClick={() => requestSpan("utensil")}
disabled={isSubmitting}
>
✕
</button>
</li>
);
})}
</ul>
<button type="button" onClick={() => requestSpan("ingredient")} disabled={isSubmitting}>
{t("recipes.techStepCorrection.addIngredient")}
</button>
</section>
<section className="tech-step-correction-popover__metadata-section">
<h4>{t("recipes.techStepCorrection.utensilsSection")}</h4>
<ul className="tech-step-correction-popover__chips">
{pendingUtensils.map((utensil, index) => {
const view = utensilById.get(utensil.utensilId);
return (
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient chip list above.
<li key={index} className="tech-step-correction-popover__chip">
{view ? t(`catalog.utensils.${view.key}`) : "…"}
<button
type="button"
onClick={() => removeUtensil(index)}
title={t("recipes.techStepCorrection.removeUtensil")}
disabled={isSubmitting}
>
✕
</button>
</li>
);
})}
</ul>
<button type="button" onClick={() => requestSpan("utensil")} disabled={isSubmitting}>
<PlusIcon />
{t("recipes.techStepCorrection.addUtensil")}
</button>
)}
</section>
<section className="tech-step-correction-popover__section tech-step-correction-popover__temperature-section">
<SectionHeader
icon={<ThermometerIcon />}
title={t("recipes.techStepCorrection.temperatureSection")}
count={pendingTemperatures.length}
/>
{pendingTemperatures.length > 0 && (
<ul className="tech-step-correction-popover__rows">
{pendingTemperatures.map((temperature, index) => (
<MetadataRow
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient row list above.
key={index}
label={temperatureLabel(temperature, t)}
quote={temperature.quote}
onRemove={() => removeTemperature(index)}
removeLabel={t("recipes.techStepCorrection.removeTemperature")}
disabled={isSubmitting}
/>
))}
</ul>
)}
{awaitingSpanFor === "temperature" ? (
<p className="tech-step-correction-popover__hint">
{t("recipes.techStepCorrection.selectSpanHint")}
</p>
) : activeSpan?.kind === "temperature" ? (
<div className="tech-step-correction-popover__add-form">
<div className="tech-step-correction-popover__mode-switch">
<button
type="button"
className={tempMode === "celsius" ? "active" : ""}
onClick={() => setTempMode("celsius")}
>
{t("recipes.techStepCorrection.temperatureMode.celsius")}
</button>
<button
type="button"
className={tempMode === "gasMark" ? "active" : ""}
onClick={() => setTempMode("gasMark")}
>
{t("recipes.techStepCorrection.temperatureMode.gasMark")}
</button>
<button
type="button"
className={tempMode === "qualitative" ? "active" : ""}
onClick={() => setTempMode("qualitative")}
>
{t("recipes.techStepCorrection.temperatureMode.qualitative")}
</button>
</div>
{tempMode === "celsius" && (
<div className="tech-step-correction-popover__value-line">
<input
type="number"
value={tempCelsiusValue}
onChange={(e) => setTempCelsiusValue(e.target.value)}
aria-label={t("recipes.techStepCorrection.temperatureMode.celsius")}
/>
<span className="tech-step-correction-popover__value-unit">°C</span>
</div>
)}
{tempMode === "gasMark" && (
<div className="tech-step-correction-popover__value-line">
<span className="tech-step-correction-popover__value-unit">
{t("recipes.techStepCorrection.temperatureMode.gasMark")}
</span>
<input
type="number"
min="1"
step="1"
value={tempGasMarkValue}
onChange={(e) => setTempGasMarkValue(e.target.value)}
aria-label={t("recipes.techStepCorrection.temperatureMode.gasMark")}
/>
</div>
)}
{tempMode === "qualitative" && (
<div className="tech-step-correction-popover__qual-switch">
<button
type="button"
className={tempQualChoice === "low" ? "active" : ""}
onClick={() => setTempQualChoice("low")}
>
{t("recipes.temperature.qualitative.low")}
</button>
<button
type="button"
className={tempQualChoice === "medium" ? "active" : ""}
onClick={() => setTempQualChoice("medium")}
>
{t("recipes.temperature.qualitative.medium")}
</button>
<button
type="button"
className={tempQualChoice === "high" ? "active" : ""}
onClick={() => setTempQualChoice("high")}
>
{t("recipes.temperature.qualitative.high")}
</button>
</div>
)}
<div className="tech-step-correction-popover__add-form-actions">
<button
type="button"
className="tech-step-correction-popover__cancel-mini"
onClick={cancelSpanSelection}
>
{t("recipes.techStepCorrection.cancelSpanSelection")}
</button>
<button
type="button"
className="tech-step-correction-popover__confirm-mini"
onClick={confirmTemperatureSpan}
>
{t("recipes.techStepCorrection.addToList")}
</button>
</div>
</div>
) : (
<button
type="button"
className="tech-step-correction-popover__add-row"
onClick={() => requestSpan("temperature")}
disabled={isSubmitting}
>
<PlusIcon />
{t("recipes.techStepCorrection.addTemperature")}
</button>
)}
</section>
<div className="tech-step-correction-popover__actions">
<button
type="button"
className="tech-step-correction-popover__cancel"
onClick={onClose}
disabled={isSubmitting}
>
{t("recipes.techStepCorrection.cancel")}
</button>
<button
type="button"
className="tech-step-correction-popover__confirm-button"
@ -507,17 +976,10 @@ export function TechStepCorrectionPopover({
{t("recipes.techStepCorrection.confirm")}
</button>
</div>
</>
)}
{error && <p className="field-error">{error}</p>}
<button
type="button"
className="tech-step-correction-popover__cancel"
onClick={onClose}
disabled={isSubmitting}
>
{t("recipes.techStepCorrection.cancel")}
</button>
</div>
);
}

View file

@ -183,21 +183,29 @@
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.",
"confirm": "Valider",
"techniqueSection": "Technique",
"currentTechnique": "Technique retenue : {{technique}}",
"changeTechnique": "Changer",
"noTechniqueSelected": "Aucune technique sélectionnée.",
"searchTechniquePlaceholder": "Rechercher une technique…",
"noTechniqueFound": "Aucune technique trouvée.",
"ingredientsSection": "Ingrédients",
"utensilsSection": "Ustensiles",
"addIngredient": "+ Ajouter un ingrédient",
"addUtensil": "+ Ajouter un ustensile",
"temperatureSection": "Température",
"addIngredient": "Ajouter un ingrédient",
"addUtensil": "Ajouter un ustensile",
"addTemperature": "Ajouter une température",
"removeIngredient": "Retirer cet ingrédient",
"removeUtensil": "Retirer cet ustensile",
"removeTemperature": "Retirer cette température",
"selectSpanHint": "Sélectionnez le passage de texte concerné dans la description ci-dessus…",
"cancelSpanSelection": "Annuler la sélection",
"searchUtensilPlaceholder": "Rechercher un ustensile…",
"noUtensilFound": "Aucun ustensile trouvé.",
"addToList": "Ajouter"
"addToList": "Ajouter",
"temperatureMode": {
"celsius": "°C",
"gasMark": "Thermostat",
"qualitative": "Qualitatif"
}
},
"tabs": {
"favoris": "Favoris",

View file

@ -175,6 +175,48 @@ const manualStepTechStepUtensilInputSchema = z
path: ["end"],
});
/**
* A temperature mention the user points at while correcting a technique —
* same `start`/`end` span convention as
* {@link manualStepTechStepIngredientInputSchema}/
* {@link manualStepTechStepUtensilInputSchema}. Exactly one of
* `celsius`/`gasMark`/`qualitative` must be set: the same mutual-exclusivity
* invariant the auto-detection pipeline already produces (see
* `StepTechStepTemperatureView`'s doc comment, `packages/shared/src/types/recipe.ts`)
* — a manual entry can't represent "both 180 °C and Thermostat 6" any more
* than a detected one can.
*
* No `raw` field here, unlike `StepTechStepTemperatureView`/the Prisma
* column: an auto-detected mention's `raw` is the exact text a regex
* matched, but a manual one is built from a structured value the user
* picked (a number, or a qualitative level), not matched text — the
* service derives it from the step's own `description.slice(start, end)`
* instead of trusting a client-supplied copy that could drift from what's
* actually at that span.
*/
const manualStepTechStepTemperatureInputSchema = z
.object({
celsius: z.number().nullable().optional(),
gasMark: z.number().int().positive().nullable().optional(),
qualitative: z.enum(["low", "medium", "high"]).nullable().optional(),
start: z.number().int().nonnegative(),
end: z.number().int().nonnegative(),
})
.refine((temperature) => temperature.end > temperature.start, {
message: "end must be greater than start",
path: ["end"],
})
.refine(
(temperature) =>
[temperature.celsius, temperature.gasMark, temperature.qualitative].filter(
(value) => value !== null && value !== undefined,
).length === 1,
{
message: "exactly one of celsius/gasMark/qualitative is required",
path: ["celsius"],
},
);
/**
* Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` — a
* user asserting what technique a `[start, end)` span of a step's
@ -188,16 +230,22 @@ const manualStepTechStepUtensilInputSchema = z
* `description` length to validate `start`/`end` against, which this shape
* alone can't see.
*
* `ingredients`/`utensils` let the user attach metadata to the technique
* they're asserting (`correctedTechStepId`), same `source: "manual"`
* distinction the technique itself gets. **Omitted (`undefined`) means
* "leave whatever metadata already exists on this occurrence alone" —
* an explicit array, even `[]`, means "this is now the complete set,
* replace everything that was there" (auto-detected included; see
* `applyManualCorrection`'s own doc comment). This is why neither field
* has a `.default([])`: that would silently turn every plain relabel into
* a metadata wipe.** Only meaningful alongside a real `correctedTechStepId`
* — enforced by this schema's own refine below, since there's no live
* `ingredients`/`utensils`/`temperatures` let the user attach metadata to
* the technique they're asserting (`correctedTechStepId`), same
* `source: "manual"` distinction the technique itself gets. The three
* travel as **one combined bundle, not three independent ones**: **omitted
* (`undefined`) on all three at once** means "leave whatever metadata
* already exists on this occurrence alone" — as soon as *any one* of them
* is present, all three are resolved (an absent one defaults to `[]`) and
* **replace everything that was there** for that category, auto-detected
* included (see `applyManualCorrection`'s own doc comment). This is why
* none of the three has a `.default([])`: that would silently turn every
* plain relabel into a metadata wipe. A caller touching just one category
* (e.g. adding a temperature) must still resend the other two's current
* full state alongside it — same contract `TechStepCorrectionPopover.tsx`
* already follows for ingredients/utensils, just extended to a third
* category. Only meaningful alongside a real `correctedTechStepId` —
* enforced by this schema's own refine below, since there's no live
* `StepTechStep` row to attach to otherwise (removing a match, or a
* request with neither id set).
*/
@ -209,6 +257,7 @@ export const submitTechStepCorrectionSchema = z
correctedTechStepId: z.number().int().positive().nullable().optional(),
ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(),
utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
temperatures: z.array(manualStepTechStepTemperatureInputSchema).optional(),
})
.refine((input) => input.end > input.start, {
message: "end must be greater than start",
@ -224,10 +273,12 @@ export const submitTechStepCorrectionSchema = z
)
.refine(
(input) =>
(input.ingredients === undefined && input.utensils === undefined) ||
(input.ingredients === undefined &&
input.utensils === undefined &&
input.temperatures === undefined) ||
(input.correctedTechStepId ?? null) !== null,
{
message: "ingredients/utensils require a correctedTechStepId to attach to",
message: "ingredients/utensils/temperatures require a correctedTechStepId to attach to",
path: ["correctedTechStepId"],
},
);

View file

@ -587,6 +587,24 @@ ne persiste rien.
| `PATCH /recipes/:id` | `updateRecipe` (remplacement complet, pas de fusion partielle) |
| `DELETE /recipes/:id` | `deleteRecipe` (409 `RECIPE_IN_USE` si référencée par un `PlanningItem`) |
| `POST`/`DELETE /recipes/:id/favorite` | `addFavorite`/`removeFavorite` (idempotents) |
| `POST /recipes/:id/steps/:stepId/corrections` | `submitTechStepCorrection` (`recipe-tech-step-correction.service.ts`) — ouvert à tout viewer qui voit la recette, pas seulement son auteur |
| `GET /recipes/:id/steps/:stepId/corrections` | `listTechStepCorrections` — historique brut des corrections soumises pour l'étape |
**Corrections de tech-step** — une correction porte sur un `[start, end)` de
`Step.description` (`previousTechStepId`/`correctedTechStepId`, voir
[batch-cooking-modele.md](./batch-cooking-modele.md#step-step-et-techniques-détectées)).
Elle enregistre systématiquement une ligne d'audit `StepTechStepCorrection`
(jamais modifiée ensuite) **et** applique l'effet immédiatement sur la
séquence réelle `StepTechStep` de l'étape — pas seulement une suggestion en
attente pour `services/tech-step-llm-worker`. `ingredients`/`utensils`/
`temperatures` peuvent accompagner un `correctedTechStepId` réel pour
attacher/remplacer les métadonnées de cette occurrence ; les trois voyagent
comme **un seul lot** — dès que l'une est présente dans la requête, les
trois sont résolues (une absente devient `[]`, donc effacée), jamais une
fusion partielle catégorie par catégorie. `temperatures[]` exige
exactement un de `celsius`/`gasMark`/`qualitative` par entrée (même
contrainte que la détection automatique) ; son `raw` n'est jamais envoyé
par le client — dérivé côté service de `description.slice(start, end)`.
**Visibilité** (`RecipeVisibility` — `PERSONAL`/`HOUSE`/`PUBLIC`, voir
[batch-cooking-modele.md](./batch-cooking-modele.md)) contrôle uniquement la

View file

@ -379,13 +379,28 @@ dans `Step.description`, utilisé pour le surlignage côté web
Chaque `step_tech_step` porte en plus les métadonnées trouvées dans sa propre
clause : `step_tech_step_ingredient` (ingrédient résolu contre le catalogue
`ingredients` existant, `quantity`/`unit_id` optionnels quand une quantité a
pu être extraite juste avant la mention) et `step_tech_step_utensil`
pu être extraite juste avant la mention), `step_tech_step_utensil`
(ustensile résolu contre un nouveau catalogue `utensil`, même forme
minimale `id`/`key` que `tech_step` — voir
minimale `id`/`key` que `tech_step`), et `step_tech_step_temperature`
(température de la clause — `celsius`/`gas_mark`/`qualitative`
mutuellement exclusifs, un seul non-null par ligne ; `raw` conserve le
texte exact matché, ex. `"180°C"`/`"th. 6"`/`"feu doux"` ; `qualitative`
vaut `"low"`/`"medium"`/`"high"`, contrainte au niveau TypeScript
seulement — pas d'enum Postgres ici, contrairement à `AllergenKind`/
`ThemePreference`/`UnitType` ailleurs dans ce schéma). Les trois référencent
`step_tech_step` par sa clé composite `(step_id, order)`, `onDelete:
Cascade` comme le reste de cette chaîne — voir
[backend-architecture.md](./backend-architecture.md#détection-des-techniques--tech-step-matcherts)
pour comment chacun est détecté). Les deux référencent `step_tech_step` par
sa clé composite `(step_id, order)`, `onDelete: Cascade` comme le reste de
cette chaîne.
pour comment chacune est détectée.
`source` (`"auto"` | `"manual"`, même contrainte TypeScript-seule que
`qualitative` ci-dessus) marque, sur `step_tech_step` et ses trois tables de
métadonnées, si la ligne vient du classifieur ou d'une correction manuelle
soumise par un utilisateur (`POST /recipes/:id/steps/:stepId/corrections`,
voir [backend-architecture.md](./backend-architecture.md#recipe)) — cette
route remplace intégralement les trois tables de métadonnées d'une
occurrence dès que l'une d'elles est touchée ("le manuel remplace tout"),
jamais un ajout partiel.
---