Expose côté UI ce que tech-step-matcher.ts détecte déjà à la sauvegarde
(Step.techSteps) mais qui restait backend-only : dans le panneau détail
d'une recette, les mots exacts ayant déclenché une technique sont
surlignés, avec un tooltip (survol/focus clavier) donnant son nom.
- tech-step-matcher.ts : matchTechStepSpans(description, mappings) expose
désormais {techStepId, start, end} en plus de la simple séquence d'ids
(déjà calculé en interne, jusqu'ici jeté). matchTechSteps devient un
wrapper fin dessus — aucun changement à ses ~12 tests existants ni à
recipe-translation.ts.
- StepTechStep gagne start/end (nullable, pas de backfill — même leçon que
l'incident de migration ingredient_unit_catalog : NOT NULL sans défaut
sur une table déjà peuplée casse le déploiement). Une ligne pré-existante
sans span est simplement omise de la réponse API plutôt que de fuiter un
null, jusqu'à ce que la recette soit resauvegardée.
- recipe.service.ts : createRecipe/updateRecipe persistent start/end ;
StepView expose techSteps: { techStep: {id,key}, start, end }[]. Le
recalcul complet à chaque édition (ajout/modif/suppression d'étape) était
déjà garanti par le delete-then-recreate existant d'updateRecipe — testé
explicitement (nouveau test "recomputes techniques from scratch...").
- Frontend : StepDescription.tsx (découpe le texte via
highlight-tech-steps.ts, pur et testé) remplace le <p> brut dans
RecipeDetailPanel. Nouveau Tooltip.tsx (composants/ui, CSS pur, aucune
lib externe — même esprit que Dialog.tsx) : un <button> (focusable
nativement, pas de tabIndex sur un <mark> non interactif) affiche le nom
de la technique (catalog.techSteps.<key>) au survol/focus.
Tests : matchTechStepSpans (spans corrects, chevauchement résolu),
recipe.test.ts (forme API + recalcul complet sur modif/ajout/suppression
d'étape, avec vérification que les anciennes lignes StepTechStep sont bien
supprimées), splitDescriptionByTechSteps (tri, bornes invalides ignorées,
chevauchement résiduel ignoré), scénario Cypress recipes.feature
(surlignage + tooltip au focus).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
47 lines
1.9 KiB
TypeScript
47 lines
1.9 KiB
TypeScript
import type { StepTechStepView } from "@batch-cooking/shared";
|
|
|
|
/**
|
|
* One run of a step's `description` — either plain text, or the exact
|
|
* words that triggered a technique match (`techStep` set). What
|
|
* `StepDescription.tsx` renders: plain segments as-is, technique segments
|
|
* wrapped in a highlighted, tooltip-bearing `<mark>`.
|
|
*/
|
|
export interface DescriptionSegment {
|
|
text: string;
|
|
techStep: StepTechStepView["techStep"] | null;
|
|
}
|
|
|
|
/**
|
|
* Splits `description` into an ordered sequence of plain/technique
|
|
* {@link DescriptionSegment}s using each `techSteps` entry's `start`/`end`
|
|
* (see `StepTechStepView`, resolved server-side by
|
|
* `tech-step-matcher.ts`'s `matchTechStepSpans`).
|
|
*
|
|
* `techSteps` is expected already sorted by `start` (the API returns it in
|
|
* `StepTechStep.order`, which *is* reading order — see that model's schema
|
|
* doc comment) but this re-sorts defensively rather than assuming it, and
|
|
* silently drops any entry whose bounds don't make sense against
|
|
* `description` (`start < 0`, `end > description.length`, `start >= end`,
|
|
* or overlapping a previously-accepted entry) — a malformed/out-of-date
|
|
* span degrades to "just don't highlight that one" rather than a garbled
|
|
* slice or a crash.
|
|
*/
|
|
export function splitDescriptionByTechSteps(
|
|
description: string,
|
|
techSteps: StepTechStepView[],
|
|
): DescriptionSegment[] {
|
|
const sorted = [...techSteps].sort((a, b) => a.start - b.start);
|
|
|
|
const segments: DescriptionSegment[] = [];
|
|
let cursor = 0;
|
|
for (const { techStep, start, end } of sorted) {
|
|
if (start < 0 || end > description.length || start >= end || start < cursor) continue;
|
|
if (start > cursor) segments.push({ text: description.slice(cursor, start), techStep: null });
|
|
segments.push({ text: description.slice(start, end), techStep });
|
|
cursor = end;
|
|
}
|
|
if (cursor < description.length) {
|
|
segments.push({ text: description.slice(cursor), techStep: null });
|
|
}
|
|
return segments;
|
|
}
|