Compare commits

...

4 commits

Author SHA1 Message Date
81cdc55673 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
2026-09-05 08:06:04 +02:00
df9693ffac Merge pull request 'feat(recipes): refond le panneau de correction des métadonnées de tech-step' (#30) from feat/tech-step-metadata-editor-redesign into feat/tech-step-temperature-correction
Some checks failed
CI / lint (push) Successful in 3m37s
CI / build (push) Successful in 3m46s
CI / intent-service-test (push) Successful in 5m19s
CI / e2e (push) Successful in 11m22s
CI / test (push) Failing after 17m48s
2026-09-05 08:05:53 +02:00
6c5978dd1c feat(recipes): refond le panneau de correction des métadonnées de tech-step
All checks were successful
CI / lint (push) Successful in 2m35s
CI / build (push) Successful in 2m38s
CI / intent-service-test (push) Successful in 4m23s
CI / e2e (push) Successful in 8m0s
CI / test (push) Successful in 15m1s
Suite à la maquette validée : redessine TechStepCorrectionPopover et ajoute
l'édition de la température, jusqu'ici la seule des 4 métadonnées sans
aucune interface (ni dans l'UI, ni côté API — voir la PR back-end empilée
sous celle-ci).

- StepDescription.tsx : le surlignage de température devient cliquable
  (un <button>, plus un <span> mort) — rouvre le popover de correction de
  la technique à laquelle il est rattaché (findTechStepForTemperature,
  résolu par span puisque la température n'a pas d'existence propre, elle
  est une métadonnée de clause comme les ingrédients/ustensiles).
- TechStepCorrectionPopover.tsx : réécrit. Les 4 sections (Technique,
  Ingrédients, Ustensiles, Température) restent toutes visibles en même
  temps — plus de bascule de tout le popover vers un sous-écran pendant
  l'ajout d'un ingrédient/ustensile/température, seule la section
  concernée affiche son propre mini-formulaire, les autres (dont le
  picker de technique) restent inchangées à côté. Le picker de technique
  se cache derrière "Changer" une fois une technique choisie (rien à
  décider une fois confirmée correcte). Chaque ingrédient/ustensile/
  température attaché s'affiche en ligne avec sa citation exacte du texte
  source (nouveau : la traçabilité vers le texte existait déjà en donnée,
  mais n'était affichée nulle part). Nouveau sélecteur à 3 modes
  (°C / Thermostat / Qualitatif) pour la température, cohérent avec le
  modèle de données (un seul des trois à la fois). Icônes SVG par
  catégorie (tag/feuille/poêle/thermomètre), même langage stroke-based
  que layouts/nav-icons.tsx.
- recipes.scss : nouveau langage visuel du popover (sections avec icône,
  lignes de métadonnées façon carte plutôt que puces, bouton "+ Ajouter"
  en pointillés plutôt qu'un lien texte, sélecteurs segmentés). Les deux
  surlignages (.step-tech-step, .step-temperature) passent de
  `cursor: help` à `cursor: pointer` — les deux sont maintenant de vrais
  déclencheurs cliquables.
- i18n : nouvelles clés (changeTechnique, temperatureSection,
  addTemperature, removeTemperature, temperatureMode.*) ; clé
  currentTechnique supprimée (le pictogramme remplace la phrase "Technique
  retenue : X") ; préfixe "+ " retiré de addIngredient/addUtensil (l'icône
  + le porte déjà visuellement).
- Tests : TechStepCorrectionPopover.cy.tsx largement réécrit (10 tests,
  dont 2 nouveaux sur la température) ; recipes.feature/recipes.ts : nouveau
  scénario Gherkin "cliquer un surlignage de température rouvre le popover
  de sa technique" (fixture Gratin dédiée), step "Changer" ajouté au
  scénario de relabel existant.

Vérifié : pnpm lint + build (shared/api/web) clean. Suites Cypress
exécutées localement (Chrome headless) : TechStepCorrectionPopover.cy.tsx
10/10, suite component complète 50/50, recipes.feature 5/5, recipes.cy.ts
8/8, recipe-sources.feature 6/6, planning-page.cy.ts 7/7. Un échec
pré-existant et sans rapport (recipe-form.feature, "Preloads an existing
recipe's ingredients when editing") reproduit identiquement avec ce
travail entièrement stashé — confirmé non lié.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:22:01 +02:00
5d4ed0dffb feat(recipes): permet de corriger la température d'un tech-step
All checks were successful
CI / lint (push) Successful in 2m20s
CI / build (push) Successful in 2m23s
CI / intent-service-test (push) Successful in 4m43s
CI / e2e (push) Successful in 6m17s
CI / test (push) Successful in 14m12s
Jusqu'ici, submitTechStepCorrectionSchema (POST /recipes/:id/steps/:stepId/
corrections) acceptait ingredients/utensils pour compléter les métadonnées
d'une technique corrigée, mais aucun champ temperatures — StepTechStepTemperature
n'était jamais écrit que par toStepTechStepCreateInputs, lors d'un create/update
complet de recette. La température ajoutée en #22 n'avait jamais eu son
propre circuit de correction manuelle.

- packages/shared/src/schemas/recipe.ts : nouveau
  manualStepTechStepTemperatureInputSchema (celsius/gasMark/qualitative
  mutuellement exclusifs — exactement un des trois — même span start/end
  que ingredients/utensils). Le champ temperatures rejoint ingredients/
  utensils dans le même lot combiné (les trois voyagent ensemble : dès que
  l'un est présent, les trois sont résolus, un absent devient [] donc
  effacé — jamais une fusion partielle catégorie par catégorie).
- recipe-tech-step-correction.service.ts : applyManualCorrection écrit
  désormais StepTechStepTemperature (delete+recreate, même logique "le
  manuel remplace tout" que les deux autres tables). raw n'est jamais
  envoyé par le client — dérivé de description.slice(start, end), pour ne
  pas faire confiance à une copie cliente qui pourrait diverger du texte
  réel. loadVisibleStepOrThrow retourne désormais le texte complet de la
  description (pas seulement sa longueur), nécessaire pour cette dérivation.
- specs/backend-architecture.md + specs/batch-cooking-modele.md : les deux
  routes de correction et step_tech_step_temperature n'étaient documentées
  nulle part — comblé au passage.
- Tests Mocha ajoutés (celsius/gasMark/qualitative, rejet si zéro ou
  plusieurs champs renseignés, remplacement sans accumulation, dérivation
  de raw, span hors limites).

Périmètre : back-end uniquement (voir la maquette proposée précédemment).
Le popover de correction (apps/web) ne sera mis à jour que dans une PR
suivante, empilée sur celle-ci.

Vérifié : pnpm lint + build (shared, api) clean. Mocha non exécuté — pas
de Postgres/Docker accessible dans cette session (ni Bash ni PowerShell).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:53:14 +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;
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 {
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;
&__value-unit {
color: var(--color-text-muted);
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);
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);
}
}
}
&__confirm-button {
align-self: flex-start;
padding: 0.4rem 1rem;
&__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 {
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,19 +215,55 @@ 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={className} data-offset={editable ? start : undefined}>
{segment.text}
</span>
</Tooltip>
);
}
const ownerStart = owner.start;
const ownerEnd = owner.end;
return (
<Tooltip key={key} content={label}>
<span
className={`step-temperature step-temperature--${modifier}`}
data-offset={editable ? start : undefined}
<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}
</span>
</button>
</Tooltip>
);
}
@ -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

@ -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.
---