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:
@@ -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.
|
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
|
## 0.55.3 — 2026-07-19 19:00
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -49,12 +49,23 @@ export default async function EditRecipePage({ params }: Params) {
|
|||||||
unit: ing.unit ?? "",
|
unit: ing.unit ?? "",
|
||||||
note: ing.note ?? "",
|
note: ing.note ?? "",
|
||||||
})),
|
})),
|
||||||
steps: recipe.steps.map((step) => ({
|
steps: recipe.steps.map((step) => {
|
||||||
id: step.id,
|
// Show the largest unit that divides evenly into the stored seconds,
|
||||||
instruction: step.instruction,
|
// so editing a 90-minute braise shows "90 min" rather than "5400 sec".
|
||||||
timerSeconds: step.timerSeconds ? String(step.timerSeconds) : "",
|
const seconds = step.timerSeconds ?? 0;
|
||||||
appliesTo: step.appliesTo ?? [],
|
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) => ({
|
photos: recipe.photos.map((photo) => ({
|
||||||
key: photo.storageKey,
|
key: photo.storageKey,
|
||||||
isCover: photo.isCover,
|
isCover: photo.isCover,
|
||||||
|
|||||||
@@ -46,13 +46,26 @@ type IngredientRow = {
|
|||||||
note: string;
|
note: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TimerUnit = "seconds" | "minutes" | "hours";
|
||||||
|
|
||||||
type StepRow = {
|
type StepRow = {
|
||||||
id: string;
|
id: string;
|
||||||
instruction: string;
|
instruction: string;
|
||||||
timerSeconds: string;
|
timerSeconds: string;
|
||||||
|
timerUnit: TimerUnit;
|
||||||
appliesTo: string[];
|
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 = {
|
type DishRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -110,7 +123,7 @@ function newIngredient(): IngredientRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function newStep(): StepRow {
|
function newStep(): StepRow {
|
||||||
return { id: crypto.randomUUID(), instruction: "", timerSeconds: "", appliesTo: [] };
|
return { id: crypto.randomUUID(), instruction: "", timerSeconds: "", timerUnit: "minutes", appliesTo: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function newDish(): DishRow {
|
function newDish(): DishRow {
|
||||||
@@ -241,12 +254,16 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|||||||
unit: ing.unit ?? "",
|
unit: ing.unit ?? "",
|
||||||
note: ing.note ?? "",
|
note: ing.note ?? "",
|
||||||
})));
|
})));
|
||||||
setSteps(recipe.steps.map((step) => ({
|
setSteps(recipe.steps.map((step) => {
|
||||||
id: crypto.randomUUID(),
|
const timer = step.timerSeconds !== undefined ? secondsToTimerInput(step.timerSeconds) : null;
|
||||||
instruction: step.instruction,
|
return {
|
||||||
timerSeconds: step.timerSeconds !== undefined ? String(step.timerSeconds) : "",
|
id: crypto.randomUUID(),
|
||||||
appliesTo: [],
|
instruction: step.instruction,
|
||||||
})));
|
timerSeconds: timer?.value ?? "",
|
||||||
|
timerUnit: timer?.unit ?? "minutes",
|
||||||
|
appliesTo: [],
|
||||||
|
};
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTag(raw: string) {
|
function addTag(raw: string) {
|
||||||
@@ -355,7 +372,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|||||||
.filter((s) => s.instruction.trim())
|
.filter((s) => s.instruction.trim())
|
||||||
.map((s, i) => ({
|
.map((s, i) => ({
|
||||||
instruction: s.instruction.trim(),
|
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,
|
order: i,
|
||||||
appliesTo: isBatchCook ? s.appliesTo.filter((n) => dishNames.has(n)) : [],
|
appliesTo: isBatchCook ? s.appliesTo.filter((n) => dishNames.has(n)) : [],
|
||||||
}));
|
}));
|
||||||
@@ -880,8 +897,18 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|||||||
placeholder={t("timerSeconds")}
|
placeholder={t("timerSeconds")}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeStep(i)}
|
onClick={() => removeStep(i)}
|
||||||
|
|||||||
@@ -129,14 +129,19 @@ export function ServingScaler({
|
|||||||
</div>
|
</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
|
{ingredients
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
.map((ing) => {
|
.map((ing) => {
|
||||||
const aiIng = aiScaledIngredients?.find((s) => s.rawName === ing.rawName);
|
const aiIng = aiScaledIngredients?.find((s) => s.rawName === ing.rawName);
|
||||||
return (
|
return (
|
||||||
<li key={ing.id} className="flex gap-2 text-sm group">
|
<li key={ing.id} className="contents group">
|
||||||
<span className="font-medium tabular-nums min-w-[3rem] text-right">
|
<span className="font-medium tabular-nums text-right whitespace-nowrap">
|
||||||
{aiIng
|
{aiIng
|
||||||
? formatIngredientQuantity(aiIng.quantity, aiIng.unit, unitPref)
|
? formatIngredientQuantity(aiIng.quantity, aiIng.unit, unitPref)
|
||||||
: formatIngredientQuantity(ing.quantity, ing.unit, unitPref, {
|
: formatIngredientQuantity(ing.quantity, ing.unit, unitPref, {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// 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 = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,16 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.55.3",
|
||||||
date: "2026-07-19 19:00",
|
date: "2026-07-19 19:00",
|
||||||
|
|||||||
@@ -907,7 +907,13 @@
|
|||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"steps": "Steps",
|
"steps": "Steps",
|
||||||
"stepPlaceholder": "Step {n}…",
|
"stepPlaceholder": "Step {n}…",
|
||||||
"timerSeconds": "Timer (s)",
|
"timerSeconds": "Timer",
|
||||||
|
"timerUnitAriaLabel": "Timer unit",
|
||||||
|
"timerUnit": {
|
||||||
|
"seconds": "sec",
|
||||||
|
"minutes": "min",
|
||||||
|
"hours": "hr"
|
||||||
|
},
|
||||||
"addStep": "Add step",
|
"addStep": "Add step",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"saveChanges": "Save changes",
|
"saveChanges": "Save changes",
|
||||||
|
|||||||
@@ -898,7 +898,13 @@
|
|||||||
"expand": "Développer",
|
"expand": "Développer",
|
||||||
"steps": "Étapes",
|
"steps": "Étapes",
|
||||||
"stepPlaceholder": "Étape {n}…",
|
"stepPlaceholder": "Étape {n}…",
|
||||||
"timerSeconds": "Minuteur (s)",
|
"timerSeconds": "Minuteur",
|
||||||
|
"timerUnitAriaLabel": "Unité du minuteur",
|
||||||
|
"timerUnit": {
|
||||||
|
"seconds": "sec",
|
||||||
|
"minutes": "min",
|
||||||
|
"hours": "h"
|
||||||
|
},
|
||||||
"addStep": "Ajouter une étape",
|
"addStep": "Ajouter une étape",
|
||||||
"saving": "Enregistrement…",
|
"saving": "Enregistrement…",
|
||||||
"saveChanges": "Enregistrer les modifications",
|
"saveChanges": "Enregistrer les modifications",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.55.3",
|
"version": "0.56.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.55.3",
|
"version": "0.56.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user