Merge pull request 'feat(recipes): permet de corriger la température d'un tech-step' (#29) from feat/tech-step-temperature-correction into main
Some checks failed
CI / lint (push) Successful in 3m23s
CI / build (push) Successful in 3m55s
CI / intent-service-test (push) Successful in 6m30s
CI / test (push) Failing after 9m7s
CI / e2e (push) Successful in 9m6s

This commit is contained in:
kyuno 2026-09-05 08:06:04 +02:00
commit 81cdc55673
12 changed files with 1652 additions and 387 deletions

View file

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

View file

@ -16,6 +16,9 @@ const butter = { id: 10, key: "butter" };
const pan = { id: 20, key: "pan" }; const pan = { id: 20, key: "pan" };
const gram = { id: 30, key: "gram" }; 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 * A real `StepDescription` resolves `onRequestSpan` into a fresh
* `resolvedMetadataSpan` via an actual browser text selection — out of * `resolvedMetadataSpan` via an actual browser text selection — out of
@ -29,18 +32,20 @@ function Harness({
previousTechStepId = null, previousTechStepId = null,
existingIngredients = [], existingIngredients = [],
existingUtensils = [], existingUtensils = [],
existingTemperatures = [],
onClose = () => {}, onClose = () => {},
onSubmitted = () => {}, onSubmitted = () => {},
}: Partial<{ }: Partial<{
previousTechStepId: number | null; previousTechStepId: number | null;
existingIngredients: unknown[]; existingIngredients: unknown[];
existingUtensils: unknown[]; existingUtensils: unknown[];
existingTemperatures: unknown[];
onClose: () => void; onClose: () => void;
onSubmitted: (result: unknown) => void; onSubmitted: (result: unknown) => void;
}>) { }>) {
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{ const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
nonce: number; nonce: number;
kind: "ingredient" | "utensil"; kind: "ingredient" | "utensil" | "temperature";
range: { start: number; end: number }; range: { start: number; end: number };
text: string; text: string;
} | null>(null); } | null>(null);
@ -52,13 +57,16 @@ function Harness({
<TechStepCorrectionPopover <TechStepCorrectionPopover
recipeId={2} recipeId={2}
stepId={2} stepId={2}
description={DESCRIPTION}
selectedText="Cuire" selectedText="Cuire"
range={{ start: 0, end: 5 }} range={{ start: 0, end: 5 }}
previousTechStepId={previousTechStepId} 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} existingIngredients={existingIngredients as any}
// biome-ignore lint/suspicious/noExplicitAny: see above. // biome-ignore lint/suspicious/noExplicitAny: see above.
existingUtensils={existingUtensils as any} existingUtensils={existingUtensils as any}
// biome-ignore lint/suspicious/noExplicitAny: see above.
existingTemperatures={existingTemperatures as any}
resolvedMetadataSpan={resolvedMetadataSpan} resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={(kind) => onRequestSpan={(kind) =>
setResolvedMetadataSpan({ 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.mount(<Harness />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible"); cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no // Merged editor (see TechStepCorrectionPopover's own doc comment) — no
// separate "pick, then metadata reveals itself" step, both render at // separate "pick, then metadata reveals itself" step, all four
// once, and the technique catalog goes through the same searchable // sections render at once, and the technique catalog goes through the
// `CatalogSearchPicker` as the ingredient/utensil sub-flows (a plain // same searchable `CatalogSearchPicker` as the ingredient/utensil
// unfiltered list of the real ~74-entry catalog isn't browsable). // 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( cy.get(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button", ".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
).should("have.length", 2); ).should("have.length", 2);
cy.contains("h4", "Ingrédients").should("be.visible"); cy.contains(".tech-step-correction-popover__section-title", "Ingrédients").should("be.visible");
cy.contains("h4", "Ustensiles").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"); 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", () => { it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => {
cy.mount(<Harness previousTechStepId={null} />); cy.mount(<Harness previousTechStepId={null} />);
cy.wait("@getTechSteps"); 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.contains(".tech-step-correction-popover__chosen-technique", "Aucune technique sélectionnée");
cy.mount(<Harness previousTechStepId={cook.id} />); cy.mount(<Harness previousTechStepId={cook.id} />);
cy.wait("@getTechSteps"); 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"); 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( cy.contains(".catalog-search-picker__list button", "Cuire").should(
"have.class", "have.class",
"catalog-search-picker__item--selected", "catalog-search-picker__item--selected",
@ -149,7 +163,7 @@ describe("TechStepCorrectionPopover", () => {
cy.contains("button", "Valider").should("not.be.disabled"); 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", { cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201, statusCode: 201,
body: { body: {
@ -200,14 +214,16 @@ describe("TechStepCorrectionPopover", () => {
".tech-step-correction-popover__technique-section .catalog-search-picker__list button", ".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter", "Mijoter",
).click(); ).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.contains(".catalog-search-picker button", "Beurre").click();
cy.get('input[type="number"]').type("50"); cy.get('input[type="number"]').type("50");
cy.get("select").select(String(gram.id)); 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.contains("button", "Valider").click();
cy.wait("@submitCorrection") cy.wait("@submitCorrection")
@ -221,28 +237,85 @@ describe("TechStepCorrectionPopover", () => {
{ ingredientId: butter.id, quantity: 50, unitId: gram.id, start: 20, end: 26 }, { ingredientId: butter.id, quantity: 50, unitId: gram.id, start: 20, end: 26 },
], ],
utensils: [], 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( cy.mount(
<Harness <Harness
previousTechStepId={cook.id} previousTechStepId={cook.id}
existingIngredients={[ existingIngredients={[
{ ingredient: butter, quantity: 50, unit: gram, start: 0, end: 6, source: "auto" }, { 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 // An existing match starts pre-selected on itself (see
// TechStepCorrectionPopover's own doc comment) — the metadata sections, // TechStepCorrectionPopover's own doc comment) — the metadata
// pre-seeded from `existingIngredients`/`existingUtensils`, are visible // sections, pre-seeded from `existingIngredients`/`existingUtensils`/
// immediately, no need to re-pick "Cuire" from a list first. // `existingTemperatures`, are visible immediately, no need to re-pick
// "Cuire" from a list first.
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]); cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click(); cy.contains(".tech-step-correction-popover__row", "Beurre").should("be.visible");
cy.contains(".tech-step-correction-popover__chip", "Beurre").should("not.exist"); 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", () => { 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" When I focus the highlighted technique "Cuire"
Then the tooltip should show "Mijoter (correction manuelle)" 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 Scenario: Deletes a recipe after a two-step confirmation, then clears the selection
Given the recipe catalog contains "Omelette" Given the recipe catalog contains "Omelette"
And recipe 2's detail is available And recipe 2's detail is available

View file

@ -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", () => { Given("the disliked ingredients list is empty", () => {
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); 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"); 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 // Step 2 is `omeletteDetail`'s "Cuire à la poêle." step, whose only
// existing match is `cook` (id 1) — see that fixture above. The response // existing match is `cook` (id 1) — see that fixture above. The response
// mirrors `SubmitTechStepCorrectionResult` (packages/shared): the audit // mirrors `SubmitTechStepCorrectionResult` (packages/shared): the audit
@ -96,6 +157,7 @@ Given('correcting step 2\'s "Cuire" match will succeed', () => {
source: "manual", source: "manual",
ingredients: [], ingredients: [],
utensils: [], utensils: [],
temperatures: [],
}, },
], ],
}, },
@ -110,6 +172,23 @@ Then("I should see the technique correction options", () => {
cy.get(".tech-step-correction-popover").should("be.visible"); 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" // Picking a technique only *selects* it — it takes a separate "Valider"
// click to actually submit (room was made for attaching ingredient/utensil // click to actually submit (room was made for attaching ingredient/utensil
// metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc // 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` // technique catalog is picked via the same searchable `CatalogSearchPicker`
// the ingredient/utensil sub-flows use, scoped to // the ingredient/utensil sub-flows use, scoped to
// `__technique-section` since that same search-and-pick component is // `__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) => { When("I choose {string} as the correct technique", (label: string) => {
cy.contains(".tech-step-correction-popover__technique-change", "Changer").click();
cy.contains( cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button", ".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
label, label,

View file

@ -673,7 +673,10 @@
text-decoration: underline dotted var(--color-primary); text-decoration: underline dotted var(--color-primary);
text-decoration-thickness: 1px; text-decoration-thickness: 1px;
text-underline-offset: 0.15em; 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, &:hover,
&:focus-visible { &:focus-visible {
@ -702,17 +705,31 @@
// `--color-primary`, so a temperature reads as its own kind of metadata at // `--color-primary`, so a temperature reads as its own kind of metadata at
// a glance. `--low`/`--medium`/`--high` only vary the underline weight — // a glance. `--low`/`--medium`/`--high` only vary the underline weight —
// the qualitative scale is a nuance, not three different colors. // 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 { .step-temperature {
display: inline;
padding: 0 0.15em; padding: 0 0.15em;
margin: 0;
font: inherit;
color: inherit;
background: color-mix(in srgb, var(--color-accent) 12%, transparent); background: color-mix(in srgb, var(--color-accent) 12%, transparent);
border: none;
border-radius: 0.2em; border-radius: 0.2em;
text-decoration: underline dotted var(--color-accent); text-decoration: underline dotted var(--color-accent);
text-decoration-thickness: 1px; text-decoration-thickness: 1px;
text-underline-offset: 0.15em; text-underline-offset: 0.15em;
cursor: help; cursor: pointer;
&:hover { &:hover,
&:focus-visible {
background: color-mix(in srgb, var(--color-accent) 22%, transparent); background: color-mix(in srgb, var(--color-accent) 22%, transparent);
outline: none;
} }
&--high { &--high {
@ -746,6 +763,9 @@
// in the document flow right below the step's own description instead of // in the document flow right below the step's own description instead of
// floating anchored at the selection. // floating anchored at the selection.
.tech-step-correction-popover { .tech-step-correction-popover {
display: flex;
flex-direction: column;
gap: var(--space-md);
margin-top: var(--space-xs); margin-top: var(--space-xs);
padding: var(--space-md); padding: var(--space-md);
background: var(--color-surface-alt); background: var(--color-surface-alt);
@ -754,30 +774,94 @@
&__selection { &__selection {
font-weight: 600; font-weight: 600;
margin: 0 0 var(--space-sm); margin: 0;
} }
// Technique picker + Ingrédients/Ustensiles render together as one // One shared shape for every section (Technique/Ingrédients/Ustensiles/
// screen now (see `TechStepCorrectionPopover.tsx`'s own doc comment) — // Température) — see `TechStepCorrectionPopover.tsx`'s own doc comment
// this section just needs its own small header row, the actual picker // for why all four render together, always, rather than one at a time.
// is `.catalog-search-picker` (below), reused as-is from the ingredient/ &__section {
// 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 {
display: flex; display: flex;
align-items: baseline; flex-direction: column;
justify-content: space-between;
gap: var(--space-sm); 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; padding: 0.2rem 0.5rem;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-error); color: var(--color-error);
@ -792,7 +876,8 @@
} }
} }
&__cancel { &__cancel,
&__cancel-mini {
background: none; background: none;
border: none; border: none;
color: var(--color-text-muted); color: var(--color-text-muted);
@ -802,113 +887,262 @@
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
// Shown in place of the technique list/metadata sections while // Shown in place of a section's own "+ Ajouter…" button while
// `StepDescription` is waiting on a second text selection (see // `StepDescription` is waiting on a second text selection for *that*
// `TechStepCorrectionPopover.tsx`'s own doc comment) — same styling // section (see `TechStepCorrectionPopover.tsx`'s own doc comment) — same
// intent as `.recipe-detail-panel__tech-step-hint`, a small muted aside. // styling intent as `.recipe-detail-panel__tech-step-hint`, a small
// muted aside.
&__hint { &__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); color: var(--color-text-muted);
font-style: italic; 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; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-sm); 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; display: flex;
align-items: center; align-items: center;
gap: var(--space-xs); gap: var(--space-xs);
input[type="number"] { input[type="number"] {
width: 5rem; width: 5rem;
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;
} }
} }
&__confirm { &__value-unit {
display: flex; color: var(--color-text-muted);
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); font-size: var(--font-size-sm);
background: var(--color-surface); }
border: 1px solid var(--color-border);
// 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); border-radius: var(--radius-pill);
padding: 2px;
button { button {
background: none; flex: 1;
border: none; 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); color: var(--color-text-muted);
cursor: pointer; cursor: pointer;
padding: 0;
line-height: 1;
&:hover:not(:disabled) { &.active {
color: var(--color-error); background: var(--color-accent);
color: var(--color-surface);
} }
} }
} }
&__confirm-button { &__qual-switch {
align-self: flex-start; display: flex;
padding: 0.4rem 1rem; 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-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 {
padding: var(--space-sm) var(--space-lg);
font-family: var(--font-display);
font-weight: 700;
letter-spacing: 0.02em;
color: var(--color-surface); color: var(--color-surface);
background: var(--color-primary); background: var(--color-primary);
border: none; border: none;
border-radius: var(--radius-md); border-radius: var(--radius-base);
cursor: pointer; cursor: pointer;
&:hover:not(:disabled) {
background: var(--color-primary-hover);
}
&:disabled { &:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; 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 { Fragment, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Tooltip } from "../../../components/ui/Tooltip"; import { Tooltip } from "../../../components/ui/Tooltip";
@ -7,6 +11,27 @@ import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
import { temperatureLabel } from "./temperature-label"; import { temperatureLabel } from "./temperature-label";
import { type TextSelectionRange, useTextSelection } from "./use-text-selection"; 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 * A recipe step's description, with every detected technique's exact
* matched words highlighted and given a {@link Tooltip} naming the * 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 * color, so the two are visually distinguishable at a glance rather than
* only via the tooltip text. * only via the tooltip text.
* *
* `editable` (off by default) additionally lets the viewer select text or * `editable` (off by default) additionally lets the viewer select text, or
* click an existing highlight to open a {@link TechStepCorrectionPopover} — * click an existing technique or temperature highlight, to open a
* see `use-text-selection.ts` for how a browser selection is translated * {@link TechStepCorrectionPopover} — a temperature highlight reopens
* into an absolute `[start, end)` span. When `!editable`, every segment * 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`, * renders exactly as before (no extra wrapping elements, no `data-offset`,
* no click handlers) — this mode is purely additive, not a rewrite of the * no click handlers) — this mode is purely additive, not a rewrite of the
* read-only rendering. * read-only rendering.
@ -90,18 +120,18 @@ export function StepDescription({
} | null>(null); } | null>(null);
// Routes the *next* text selection to the open `TechStepCorrectionPopover` // Routes the *next* text selection to the open `TechStepCorrectionPopover`
// (as an ingredient/utensil mention span) instead of opening a brand-new // (as an ingredient/utensil/temperature mention span) instead of opening
// correction — set when that popover calls `onRequestSpan`, cleared once // a brand-new correction — set when that popover calls `onRequestSpan`, cleared once
// `handleMouseUp` resolves the selection below. See // `handleMouseUp` resolves the selection below. See
// `TechStepCorrectionPopover.tsx`'s own doc comment for why this can live // `TechStepCorrectionPopover.tsx`'s own doc comment for why this can live
// entirely alongside the still-visible, still-selectable description // entirely alongside the still-visible, still-selectable description
// rather than needing the popover itself to move/hide. // rather than needing the popover itself to move/hide.
const [pendingSpanRequest, setPendingSpanRequest] = useState<"ingredient" | "utensil" | null>( const [pendingSpanRequest, setPendingSpanRequest] = useState<
null, "ingredient" | "utensil" | "temperature" | null
); >(null);
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{ const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
nonce: number; nonce: number;
kind: "ingredient" | "utensil"; kind: "ingredient" | "utensil" | "temperature";
range: TextSelectionRange; range: TextSelectionRange;
text: string; text: string;
} | null>(null); } | null>(null);
@ -185,19 +215,55 @@ export function StepDescription({
if (segment.temperature) { if (segment.temperature) {
// A detected temperature mention ("180°C", "feu doux") — its // A detected temperature mention ("180°C", "feu doux") — its
// own subtle highlight + a Tooltip with the normalized value. // own subtle highlight + a Tooltip with the normalized value.
// Not correctable (no popover), same as utensils; still gets a // In editable mode, clicking it reopens the correction popover
// `data-offset` in editable mode so a text selection spanning // for whichever technique occurrence it's attached to (found by
// it resolves correctly. // 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 label = temperatureLabel(segment.temperature, t);
const modifier = segment.temperature.qualitative ?? "value"; 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={className} data-offset={editable ? start : undefined}>
{segment.text}
</span>
</Tooltip>
);
}
const ownerStart = owner.start;
const ownerEnd = owner.end;
return ( return (
<Tooltip key={key} content={label}> <Tooltip key={key} content={label}>
<span <button
className={`step-temperature step-temperature--${modifier}`} type="button"
data-offset={editable ? start : undefined} 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} {segment.text}
</span> </button>
</Tooltip> </Tooltip>
); );
} }
@ -263,11 +329,13 @@ export function StepDescription({
<TechStepCorrectionPopover <TechStepCorrectionPopover
recipeId={recipeId} recipeId={recipeId}
stepId={stepId} stepId={stepId}
description={description}
range={activeCorrection.range} range={activeCorrection.range}
selectedText={activeCorrection.selectedText} selectedText={activeCorrection.selectedText}
previousTechStepId={activeCorrection.previousTechStepId} previousTechStepId={activeCorrection.previousTechStepId}
existingIngredients={activeStepTechStep?.ingredients ?? []} existingIngredients={activeStepTechStep?.ingredients ?? []}
existingUtensils={activeStepTechStep?.utensils ?? []} existingUtensils={activeStepTechStep?.utensils ?? []}
existingTemperatures={activeStepTechStep?.temperatures ?? []}
resolvedMetadataSpan={resolvedMetadataSpan} resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={setPendingSpanRequest} onRequestSpan={setPendingSpanRequest}
onClose={closeActiveCorrection} onClose={closeActiveCorrection}

View file

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

View file

@ -175,6 +175,48 @@ const manualStepTechStepUtensilInputSchema = z
path: ["end"], 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 * Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` — a
* user asserting what technique a `[start, end)` span of a step's * 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 * `description` length to validate `start`/`end` against, which this shape
* alone can't see. * alone can't see.
* *
* `ingredients`/`utensils` let the user attach metadata to the technique * `ingredients`/`utensils`/`temperatures` let the user attach metadata to
* they're asserting (`correctedTechStepId`), same `source: "manual"` * the technique they're asserting (`correctedTechStepId`), same
* distinction the technique itself gets. **Omitted (`undefined`) means * `source: "manual"` distinction the technique itself gets. The three
* "leave whatever metadata already exists on this occurrence alone" — * travel as **one combined bundle, not three independent ones**: **omitted
* an explicit array, even `[]`, means "this is now the complete set, * (`undefined`) on all three at once** means "leave whatever metadata
* replace everything that was there" (auto-detected included; see * already exists on this occurrence alone" — as soon as *any one* of them
* `applyManualCorrection`'s own doc comment). This is why neither field * is present, all three are resolved (an absent one defaults to `[]`) and
* has a `.default([])`: that would silently turn every plain relabel into * **replace everything that was there** for that category, auto-detected
* a metadata wipe.** Only meaningful alongside a real `correctedTechStepId` * included (see `applyManualCorrection`'s own doc comment). This is why
* — enforced by this schema's own refine below, since there's no live * 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 * `StepTechStep` row to attach to otherwise (removing a match, or a
* request with neither id set). * request with neither id set).
*/ */
@ -209,6 +257,7 @@ export const submitTechStepCorrectionSchema = z
correctedTechStepId: z.number().int().positive().nullable().optional(), correctedTechStepId: z.number().int().positive().nullable().optional(),
ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(), ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(),
utensils: z.array(manualStepTechStepUtensilInputSchema).optional(), utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
temperatures: z.array(manualStepTechStepTemperatureInputSchema).optional(),
}) })
.refine((input) => input.end > input.start, { .refine((input) => input.end > input.start, {
message: "end must be greater than start", message: "end must be greater than start",
@ -224,10 +273,12 @@ export const submitTechStepCorrectionSchema = z
) )
.refine( .refine(
(input) => (input) =>
(input.ingredients === undefined && input.utensils === undefined) || (input.ingredients === undefined &&
input.utensils === undefined &&
input.temperatures === undefined) ||
(input.correctedTechStepId ?? null) !== null, (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"], path: ["correctedTechStepId"],
}, },
); );

View file

@ -587,6 +587,24 @@ ne persiste rien.
| `PATCH /recipes/:id` | `updateRecipe` (remplacement complet, pas de fusion partielle) | | `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`) | | `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`/`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 **Visibilité** (`RecipeVisibility` — `PERSONAL`/`HOUSE`/`PUBLIC`, voir
[batch-cooking-modele.md](./batch-cooking-modele.md)) contrôle uniquement la [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 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 clause : `step_tech_step_ingredient` (ingrédient résolu contre le catalogue
`ingredients` existant, `quantity`/`unit_id` optionnels quand une quantité a `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 (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) [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 pour comment chacune est détectée.
sa clé composite `(step_id, order)`, `onDelete: Cascade` comme le reste de
cette chaîne. `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.
--- ---