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
+8
View File
@@ -2,6 +2,14 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.56.0 — 2026-07-20 09:10
### Added
- Step timers in the recipe editor now take a unit (seconds/minutes/hours) instead of forcing everyone to do the math into seconds. Editing an existing recipe shows the timer in whichever unit divides evenly into what's stored.
### Fixed
- Ingredient list on the recipe page didn't line up — the quantity column only had a minimum width, so a longer value pushed that row's ingredient name further right than the others. Switched to a shared grid column so every row's name starts at the same spot.
## 0.55.3 — 2026-07-19 19:00
### Fixed
+17 -6
View File
@@ -49,12 +49,23 @@ export default async function EditRecipePage({ params }: Params) {
unit: ing.unit ?? "",
note: ing.note ?? "",
})),
steps: recipe.steps.map((step) => ({
id: step.id,
instruction: step.instruction,
timerSeconds: step.timerSeconds ? String(step.timerSeconds) : "",
appliesTo: step.appliesTo ?? [],
})),
steps: recipe.steps.map((step) => {
// Show the largest unit that divides evenly into the stored seconds,
// so editing a 90-minute braise shows "90 min" rather than "5400 sec".
const seconds = step.timerSeconds ?? 0;
const timer = seconds > 0 && seconds % 3600 === 0
? { value: String(seconds / 3600), unit: "hours" as const }
: seconds > 0 && seconds % 60 === 0
? { value: String(seconds / 60), unit: "minutes" as const }
: { value: seconds > 0 ? String(seconds) : "", unit: "seconds" as const };
return {
id: step.id,
instruction: step.instruction,
timerSeconds: timer.value,
timerUnit: timer.unit,
appliesTo: step.appliesTo ?? [],
};
}),
photos: recipe.photos.map((photo) => ({
key: photo.storageKey,
isCover: photo.isCover,
+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, {
+11 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.55.3";
export const APP_VERSION = "0.56.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,16 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.56.0",
date: "2026-07-20 09:10",
added: [
"Step timers in the recipe editor now take a unit (seconds/minutes/hours) instead of forcing everyone to do the math into seconds — a 90-minute braise is just \"90 min\" now. Editing an existing recipe shows the timer in whichever unit divides evenly into what's stored.",
],
fixed: [
"Ingredient list on the recipe page didn't line up — the quantity column only had a minimum width, so any longer value (e.g. an appended conversion like \"(~2 tbsp)\") pushed that row's ingredient name further right than the others. Switched to a shared grid column so every row's name starts at the same spot.",
],
},
{
version: "0.55.3",
date: "2026-07-19 19:00",
+7 -1
View File
@@ -907,7 +907,13 @@
"expand": "Expand",
"steps": "Steps",
"stepPlaceholder": "Step {n}…",
"timerSeconds": "Timer (s)",
"timerSeconds": "Timer",
"timerUnitAriaLabel": "Timer unit",
"timerUnit": {
"seconds": "sec",
"minutes": "min",
"hours": "hr"
},
"addStep": "Add step",
"saving": "Saving…",
"saveChanges": "Save changes",
+7 -1
View File
@@ -898,7 +898,13 @@
"expand": "Développer",
"steps": "Étapes",
"stepPlaceholder": "Étape {n}…",
"timerSeconds": "Minuteur (s)",
"timerSeconds": "Minuteur",
"timerUnitAriaLabel": "Unité du minuteur",
"timerUnit": {
"seconds": "sec",
"minutes": "min",
"hours": "h"
},
"addStep": "Ajouter une étape",
"saving": "Enregistrement…",
"saveChanges": "Enregistrer les modifications",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.55.3",
"version": "0.56.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.55.3",
"version": "0.56.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",