feat: timer unit selector + fix ingredient list alignment (v0.56.0)

Step timer input was seconds-only, no unit — a 90-minute braise meant
typing 5400. Added a seconds/minutes/hours <select> next to the input;
StepRow gets a timerUnit field, converted to seconds at submit. Editing
an existing recipe (and the AI-regenerate flow) picks the largest unit
that divides evenly into the stored seconds so it displays naturally
instead of always falling back to raw seconds.

Ingredient list (serving-scaler.tsx): the quantity column used
min-w-[3rem] on a flex child, which is only a *minimum* — any row whose
formatted quantity text (e.g. an appended "(~2 tbsp)" conversion) exceeded
that width pushed just that row's ingredient name further right,
breaking alignment across the list. Switched the list to a CSS grid with
`display: contents` on each <li>, so the quantity column's width is
shared across every row instead of sized per-row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-20 08:58:18 +02:00
parent 37739699f9
commit 2f18462548
9 changed files with 96 additions and 23 deletions
+36 -9
View File
@@ -46,13 +46,26 @@ type IngredientRow = {
note: string;
};
type TimerUnit = "seconds" | "minutes" | "hours";
type StepRow = {
id: string;
instruction: string;
timerSeconds: string;
timerUnit: TimerUnit;
appliesTo: string[];
};
const TIMER_UNIT_SECONDS: Record<TimerUnit, number> = { seconds: 1, minutes: 60, hours: 3600 };
/** Picks the largest unit that divides evenly into the stored seconds, so
* editing a 90-minute braise shows "90 min" rather than "5400 sec". */
function secondsToTimerInput(totalSeconds: number): { value: string; unit: TimerUnit } {
if (totalSeconds > 0 && totalSeconds % 3600 === 0) return { value: String(totalSeconds / 3600), unit: "hours" };
if (totalSeconds > 0 && totalSeconds % 60 === 0) return { value: String(totalSeconds / 60), unit: "minutes" };
return { value: String(totalSeconds), unit: "seconds" };
}
type DishRow = {
id: string;
name: string;
@@ -110,7 +123,7 @@ function newIngredient(): IngredientRow {
}
function newStep(): StepRow {
return { id: crypto.randomUUID(), instruction: "", timerSeconds: "", appliesTo: [] };
return { id: crypto.randomUUID(), instruction: "", timerSeconds: "", timerUnit: "minutes", appliesTo: [] };
}
function newDish(): DishRow {
@@ -241,12 +254,16 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
unit: ing.unit ?? "",
note: ing.note ?? "",
})));
setSteps(recipe.steps.map((step) => ({
id: crypto.randomUUID(),
instruction: step.instruction,
timerSeconds: step.timerSeconds !== undefined ? String(step.timerSeconds) : "",
appliesTo: [],
})));
setSteps(recipe.steps.map((step) => {
const timer = step.timerSeconds !== undefined ? secondsToTimerInput(step.timerSeconds) : null;
return {
id: crypto.randomUUID(),
instruction: step.instruction,
timerSeconds: timer?.value ?? "",
timerUnit: timer?.unit ?? "minutes",
appliesTo: [],
};
}));
}
function addTag(raw: string) {
@@ -355,7 +372,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
.filter((s) => s.instruction.trim())
.map((s, i) => ({
instruction: s.instruction.trim(),
timerSeconds: s.timerSeconds ? parseInt(s.timerSeconds) : undefined,
timerSeconds: s.timerSeconds ? parseInt(s.timerSeconds) * TIMER_UNIT_SECONDS[s.timerUnit] : undefined,
order: i,
appliesTo: isBatchCook ? s.appliesTo.filter((n) => dishNames.has(n)) : [],
}));
@@ -880,8 +897,18 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
placeholder={t("timerSeconds")}
type="number"
min={0}
className="w-28 shrink-0"
className="w-20 shrink-0"
/>
<select
value={step.timerUnit}
onChange={(e) => updateStep(i, { timerUnit: e.target.value as TimerUnit })}
aria-label={t("timerUnitAriaLabel")}
className="h-8 shrink-0 rounded-lg border border-input bg-transparent px-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="seconds">{t("timerUnit.seconds")}</option>
<option value="minutes">{t("timerUnit.minutes")}</option>
<option value="hours">{t("timerUnit.hours")}</option>
</select>
<button
type="button"
onClick={() => removeStep(i)}
@@ -129,14 +129,19 @@ export function ServingScaler({
</div>
)}
<ul className="space-y-2">
{/* grid + `contents` on each <li>, not flex — a flex child's quantity
column only has a *minimum* width, so it drifts row-to-row once any
value's text (e.g. an appended "(~2 tbsp)" conversion) exceeds that
minimum. A shared grid track sizes to the widest cell across every
row, so the name column lines up regardless of quantity length. */}
<ul className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-2 text-sm">
{ingredients
.sort((a, b) => a.order - b.order)
.map((ing) => {
const aiIng = aiScaledIngredients?.find((s) => s.rawName === ing.rawName);
return (
<li key={ing.id} className="flex gap-2 text-sm group">
<span className="font-medium tabular-nums min-w-[3rem] text-right">
<li key={ing.id} className="contents group">
<span className="font-medium tabular-nums text-right whitespace-nowrap">
{aiIng
? formatIngredientQuantity(aiIng.quantity, aiIng.unit, unitPref)
: formatIngredientQuantity(ing.quantity, ing.unit, unitPref, {