Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api/src/models/Recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ export interface Recipe extends Document {
notes?: string;
owner: Types.ObjectId;
source?: string;
activeTime?: number;
passiveTime?: number;
numServings: number;
isIngredient: boolean;
createdAt: Date;
Expand Down Expand Up @@ -248,6 +250,8 @@ const recipeSchema = new Schema<Recipe>({
notes: { type: String },
owner: { type: Schema.Types.ObjectId, required: true, ref: 'User', validate: ownerExists() },
source: { type: String },
activeTime: { type: Number },
passiveTime: { type: Number },
numServings: { type: Number, required: true },
isIngredient: { type: Boolean, required: true },
createdAt: { type: Date, required: true },
Expand Down
17 changes: 17 additions & 0 deletions api/src/scripts/updateSchema_2026-03-30.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/* eslint-env mongo */
const collectionName = process.env.COLLECTION;
if (!collectionName) {
print('Error: Please provide a collection name via COLLECTION env var.');
quit(1);
}
const db = db.getSiblingDB(collectionName);

const result = db.recipes.updateMany(
{ $or: [{ activeTime: { $exists: false } }, { passiveTime: { $exists: false } }] },
{ $set: { activeTime: null, passiveTime: null } }
);

print(`Migration complete. Updated ${result.modifiedCount} document(s).`);

// Usage:
// COLLECTION=recipeDb mongosh "mongodb://localhost:27017" updateSchema_2026-03-30.js
63 changes: 63 additions & 0 deletions api/test/graphql/Recipe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,39 @@ describe('recipeCreateOne', () => {
);
});

it('should create a recipe with active time', async function () {
const user = await User.findOne({ username: 'testuser1' });
const ingredient = await Ingredient.findOne({ name: 'chicken' });
const unit = await Unit.findOne({ shortSingular: 'g' });
const prepMethod = await PrepMethod.findOne({ value: 'chopped' });
const newRecord = { ...getDefaultRecipeRecord(ingredient, unit, prepMethod), activeTime: 45 };
const response = await createRecipe(this, user, newRecord);
const record = parseCreatedRecipe(response);
assert.equal(record.title, 'Chicken Soup');
const doc = await Recipe.findById(record._id);
assert.equal(doc.activeTime, 45);
assert.isUndefined(doc.passiveTime);
});

it('should create a recipe with both timings', async function () {
const user = await User.findOne({ username: 'testuser1' });
const ingredient = await Ingredient.findOne({ name: 'chicken' });
const unit = await Unit.findOne({ shortSingular: 'g' });
const prepMethod = await PrepMethod.findOne({ value: 'chopped' });
const newRecord = {
...getDefaultRecipeRecord(ingredient, unit, prepMethod),
title: 'Chicken Broth',
activeTime: 30,
passiveTime: 120,
};
const response = await createRecipe(this, user, newRecord);
const record = parseCreatedRecipe(response);
assert.equal(record.title, 'Chicken Broth');
const doc = await Recipe.findById(record._id);
assert.equal(doc.activeTime, 30);
assert.equal(doc.passiveTime, 120);
});

it('should generate different suffixes for different recipes with same title', async function () {
const user = await User.findOne({ username: 'testuser1' });
const ingredient = await Ingredient.findOne({ name: 'chicken' });
Expand Down Expand Up @@ -1091,6 +1124,36 @@ describe('recipeUpdateById', () => {
);
});

it('should update active time on a recipe', async function () {
const user = await User.findOne({ username: 'testuser1' });
const ingredient = await Ingredient.findOne({ name: 'chicken' });
const unit = await Unit.findOne({ shortSingular: 'g' });
const prepMethod = await PrepMethod.findOne({ value: 'chopped' });
const newRecipe = new Recipe(getDefaultRecipe(user, ingredient, unit, prepMethod));
const recipe = await newRecipe.save();
const response = await updateRecipe(this, user, recipe._id, { activeTime: 90 });
const record = parseUpdatedRecipe(response);
const doc = await Recipe.findById(record._id);
assert.equal(doc.activeTime, 90);
});

it('should update both timings on a recipe', async function () {
const user = await User.findOne({ username: 'testuser1' });
const ingredient = await Ingredient.findOne({ name: 'chicken' });
const unit = await Unit.findOne({ shortSingular: 'g' });
const prepMethod = await PrepMethod.findOne({ value: 'chopped' });
const newRecipe = new Recipe(getDefaultRecipe(user, ingredient, unit, prepMethod));
const recipe = await newRecipe.save();
const response = await updateRecipe(this, user, recipe._id, {
activeTime: 45,
passiveTime: 60,
});
const record = parseUpdatedRecipe(response);
const doc = await Recipe.findById(record._id);
assert.equal(doc.activeTime, 45);
assert.equal(doc.passiveTime, 60);
});

// URL Suffix preservation tests
it('should preserve the random suffix when updating a recipe title', async function () {
const user = await User.findOne({ username: 'testuser1' });
Expand Down
145 changes: 145 additions & 0 deletions client/src/__tests__/index.timings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { userEvent } from '@testing-library/user-event';
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, screen, within } from '@testing-library/react';
import { loadDevMessages, loadErrorMessages } from '@apollo/client/dev';

import { PATH } from '@recipe/constants';
import { MockedResponses, renderPage } from '@recipe/utils/tests';
import { enterEditRecipePage, enterViewRecipePage } from '@recipe/utils/tests';
import { mockGetRecipeOneWithTimings } from '@recipe/graphql/queries/__mocks__/recipe';
import { mockUpdateRecipeAddActiveTime } from '@recipe/graphql/mutations/__mocks__/recipe';
import { mockUpdateRecipeAddBothTimings } from '@recipe/graphql/mutations/__mocks__/recipe';
import { mockUpdateRecipeAddPassiveTime } from '@recipe/graphql/mutations/__mocks__/recipe';
import { mockUpdateRecipeRemoveActiveTime } from '@recipe/graphql/mutations/__mocks__/recipe';
import { mockUpdateRecipeUpdateActiveTime } from '@recipe/graphql/mutations/__mocks__/recipe';

import { routes } from '../routes';
import { renderComponent } from './utils';
import { mocks } from '../__mocks__/graphql';

loadErrorMessages();
loadDevMessages();

/**
* Like renderComponent, but places priorityMocks BEFORE the default mocks
* so they are consumed first by Apollo MockedProvider.
*/
function renderWithPriorityMocks(priorityMocks: MockedResponses, extraMocks: MockedResponses = []) {
return renderPage(routes, [...priorityMocks, ...mocks, ...extraMocks], [PATH.ROOT]);
}

function getSelectInput(ariaLabel: string): HTMLInputElement {
const all = screen.getAllByLabelText(ariaLabel);
// Mantine Select renders two inputs: a hidden one and a visible one.
// We want the visible combobox input (role=combobox or type=search).
const visible = all.find(
(el) => el.getAttribute('role') === 'combobox' || el.getAttribute('type') === 'search'
);
return (visible ?? all[0]) as HTMLInputElement;
}

async function selectMantineOption(
user: ReturnType<typeof userEvent.setup>,
ariaLabel: string,
value: string
) {
const input = getSelectInput(ariaLabel);
await user.click(input);
const listbox = await screen.findByRole('listbox');
await user.click(within(listbox).getByText(value));
}

describe('Update Recipe Workflow: Timings', () => {
afterEach(() => {
cleanup();
});

it('should add an active time', async () => {
// Render -----------------------------------------------
renderComponent([mockUpdateRecipeAddActiveTime]);
const user = userEvent.setup();

// Act --------------------------------------------------
await enterEditRecipePage(screen, user, 'Mock Recipe', 'Instruction one.');
await selectMantineOption(user, 'active time hours', '1');
await selectMantineOption(user, 'active time minutes', '30');
await user.click(screen.getByLabelText('Save recipe'));

// Expect ------------------------------------------------
// ------ View Recipe Page -------------------------------
await enterViewRecipePage(screen, user, 'Mock Recipe', 'Active: 1 hr 30 min');
});

it('should add a passive time', async () => {
// Render -----------------------------------------------
renderComponent([mockUpdateRecipeAddPassiveTime]);
const user = userEvent.setup();

// Act --------------------------------------------------
await enterEditRecipePage(screen, user, 'Mock Recipe', 'Instruction one.');
await selectMantineOption(user, 'passive time hours', '2');
await selectMantineOption(user, 'passive time minutes', '0');
await user.click(screen.getByLabelText('Save recipe'));

// Expect ------------------------------------------------
// ------ View Recipe Page -------------------------------
await enterViewRecipePage(screen, user, 'Mock Recipe', 'Passive: 2 hr');
});

it('should add both active and passive times', async () => {
// Render -----------------------------------------------
renderComponent([mockUpdateRecipeAddBothTimings]);
const user = userEvent.setup();

// Act --------------------------------------------------
await enterEditRecipePage(screen, user, 'Mock Recipe', 'Instruction one.');
await selectMantineOption(user, 'active time hours', '1');
await selectMantineOption(user, 'active time minutes', '30');
await selectMantineOption(user, 'passive time hours', '2');
await selectMantineOption(user, 'passive time minutes', '0');
await user.click(screen.getByLabelText('Save recipe'));

// Expect ------------------------------------------------
// ------ View Recipe Page -------------------------------
await enterViewRecipePage(screen, user, 'Mock Recipe', 'Active: 1 hr 30 min');
expect(screen.getByText('Passive: 2 hr')).not.toBeNull();
});

it('should remove an active time', async () => {
// Render -----------------------------------------------
// mockGetRecipeOneWithTimings must be consumed BEFORE the default
// mockGetRecipeOne so the edit page loads the recipe with timings.
renderWithPriorityMocks([mockGetRecipeOneWithTimings], [mockUpdateRecipeRemoveActiveTime]);
const user = userEvent.setup();

// Act --------------------------------------------------
await enterEditRecipePage(screen, user, 'Mock Recipe', 'Instruction one.');
// Setting both hours and minutes to 0 triggers onChange(null)
await selectMantineOption(user, 'active time hours', '0');
await selectMantineOption(user, 'active time minutes', '0');
await user.click(screen.getByLabelText('Save recipe'));

// Expect ------------------------------------------------
// ------ View Recipe Page -------------------------------
await enterViewRecipePage(screen, user, 'Mock Recipe', 'Passive: 2 hr');
expect(screen.queryByText(/Active/)).toBeNull();
});

it('should update an active time', async () => {
// Render -----------------------------------------------
// mockGetRecipeOneWithTimings must be consumed BEFORE the default
// mockGetRecipeOne so the edit page loads the recipe with timings.
renderWithPriorityMocks([mockGetRecipeOneWithTimings], [mockUpdateRecipeUpdateActiveTime]);
const user = userEvent.setup();

// Act --------------------------------------------------
await enterEditRecipePage(screen, user, 'Mock Recipe', 'Instruction one.');
await selectMantineOption(user, 'active time hours', '0');
await selectMantineOption(user, 'active time minutes', '45');
await user.click(screen.getByLabelText('Save recipe'));

// Expect ------------------------------------------------
// ------ View Recipe Page -------------------------------
await enterViewRecipePage(screen, user, 'Mock Recipe', 'Active: 45 min');
});
});
20 changes: 17 additions & 3 deletions client/src/features/editing/components/EditableRecipe.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Container, Grid, GridItem, useBreakpointValue } from '@chakra-ui/react';
import { Box, Container, Flex, Grid, GridItem, useBreakpointValue } from '@chakra-ui/react';

import { useUser } from '@recipe/features/user';
import { Servings } from '@recipe/features/servings';
Expand All @@ -11,6 +11,7 @@ import { CreateOneRecipeCreateInput } from '@recipe/graphql/generated';
import { SubmitButton } from './SubmitButton';
import { EditableNotes } from './EditableNotes';
import { EditableTitle } from './EditableTitle';
import { EditableTimings } from './EditableTimings';
import { EditableInstructionsTab } from './EditableInstructionsTab';
import { EditableIngredientSubsections } from './EditableIngredientSubsections';

Expand Down Expand Up @@ -77,9 +78,22 @@ export function EditableRecipe(props: Props) {
pt='6'
pr='6'
pb='2'
minH={{ base: '134px', md: '140px' }}
minH={{ base: 'auto', md: '140px' }}
>
<EditableTagList />
<Flex
direction={{ base: 'column', md: 'row' }}
gap={4}
width='100%'
height='100%'
align={{ base: 'stretch', md: 'flex-start' }}
>
<Box flex='1'>
<EditableTagList />
</Box>
<Box flexShrink={0}>
<EditableTimings />
</Box>
</Flex>
</GridItem>
<GridItem
area='ingredients'
Expand Down
88 changes: 88 additions & 0 deletions client/src/features/editing/components/EditableTimings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useShallow } from 'zustand/shallow';
import { Group, Select, Stack, Text } from '@mantine/core';

import { useRecipeStore } from '@recipe/stores';

const HOUR_OPTIONS = Array.from({ length: 25 }, (_, i) => String(i));
const MINUTE_OPTIONS = ['0', '5', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];

interface TimingPickerProps {
label: string;
value: number | null;
onChange: (value: number | null) => void;
ariaLabel: string;
}

function TimingPicker({ label, value, onChange, ariaLabel }: TimingPickerProps) {
const hours = value !== null ? String(Math.floor(value / 60)) : null;
const minutes = value !== null ? String(value % 60) : null;

const handleChange = (newHours: string | null, newMinutes: string | null) => {
if (newHours === null && newMinutes === null) {
onChange(null);
return;
}
const h = newHours !== null ? parseInt(newHours, 10) : 0;
const m = newMinutes !== null ? parseInt(newMinutes, 10) : 0;
const total = h * 60 + m;
onChange(total === 0 ? null : total);
};

return (
<Stack gap={4}>
<Text size='sm' fw={500}>
{label}
</Text>
<Group gap='xs' wrap='nowrap'>
<Select
placeholder='-- hr'
data={HOUR_OPTIONS}
value={hours}
onChange={(h) => handleChange(h, minutes)}
allowDeselect
clearable
w={80}
aria-label={`${ariaLabel} hours`}
/>
<Select
placeholder='-- min'
data={MINUTE_OPTIONS}
value={minutes}
onChange={(m) => handleChange(hours, m)}
allowDeselect
clearable
w={90}
aria-label={`${ariaLabel} minutes`}
/>
</Group>
</Stack>
);
}

export function EditableTimings() {
const { activeTime, setActiveTime, passiveTime, setPassiveTime } = useRecipeStore(
useShallow((state) => ({
activeTime: state.activeTime,
setActiveTime: state.setActiveTime,
passiveTime: state.passiveTime,
setPassiveTime: state.setPassiveTime,
}))
);

return (
<Group gap='xl' align='flex-start'>
<TimingPicker
label='Active Time'
value={activeTime}
onChange={setActiveTime}
ariaLabel='active time'
/>
<TimingPicker
label='Passive Time'
value={passiveTime}
onChange={setPassiveTime}
ariaLabel='passive time'
/>
</Group>
);
}
Loading