Best way to filter recipe table by cook time

Hi all,

I am building a recipe table app that pulls in data from an API. I am trying to learn filtering.
In this case, I want to filter by cook time.

The cook times come in the following formats: 10 mins, 1 hr, 1 hr 15 mins.
For example, recipe object/s might look something like this:

{
  recipeName: 'Baked Eggs',
  cookTime: '1 hr 25 mins'
},
{
  recipeName: 'Scrambled Eggs',
  cookTime: '5 mins'
},
{
  recipeName: 'Pumpkin Soup',
  cookTime: '45 mins'
}

So far I am able to do a strict match and sometimes “include” other cook times with this function:

filter: value => {
  if (!selectedCookTime) return true;
 
  if (selectedCookTime.includes("hr")) {
    // this captures all recipes of the same "hr"
    return value.startsWith(selectedCookTime);
  }
  // this returns a strict cook time match.
  return value === selectedCookTime;

  // how to do a match that includes recipes LESS THAN 
  // and including the selectedCookTime ??
}

But it’s not robust enough to filter table items, say 1 hr 15mins and below.
Any advice on how to craft this function would be much appreciated.

Thanks in advance!

Convert all the cook times to minutes or seconds first

:grin: Snap

Yeah, what you have at the minute from the API are human-readable cooking times, they aren’t some much use for computers

Why not make cookTime a number (in mins) like the following?

{
  recipeName: 'Baked Eggs',
  cookTime: 85
},
{
  recipeName: 'Scrambled Eggs',
  cookTime: 5
},
{
  recipeName: 'Pumpkin Soup',
  cookTime: 45
}

Then when ever you display the cookTime, you have a helper function which converts it back to a readable format like 1 hr 25 mins. This way, your filter could just compare numbers.

Thanks @DanCouper / @RandellDawson - yeah that makes total sense to convert it to a whole number first. The thing is that the data comes in as a human readable format already.

So I suppose the logic to convert it would be something along the lines of:

// 1. split the time string at hours and minutes
const cookTimes = timeStr.match(/(\d+)/g);
//  2. multiple hours by 60 and add to minutes to get total time
const hrs = ( cookTimes[0] * 60 ); 
const mins = cookTimes[1];
const totalTime = hrs + mins;
// 3. run the comparison against each value
// 4. (maybe) convert back to readable string

Here’s the conversion function I wrote:

function cookTimeParser(timeStr) {
  // 1. split the time string at hours and minutes
  const cookTimes = timeStr.match(/(\d+)/g);
  //  2. multiple hours by 60 and add to minutes to get total time
  const hrs = Number(cookTimes[0]) || 0;
  const mins = Number(cookTimes[1]) || 0;

  let totalTime = hrs + mins;

  if (cookTimes.length > 1 || timeStr.includes("hr")) {
    totalTime = hrs * 60 + mins;
  }

  console.log({ cookTimes, mins, hrs, totalTime });

  return totalTime;
}

Hi @JackEdwardLyons, I found your problem interesting and decided to give it a go, I hope you don’t mind :smile:

My idea is to map (actually Reduce) the API data when you get it, so that you can add a new value cookTimeInNumber thus having a new dataSet that includes both a time number in minutes and the human readable time string.

(is in TS, but It don’t matter much)

// API type
type Recipe = {
  recipeName: string,
  cookTime: string,
}

// we want to end with this
type RecipeWithTime = {
  recipeName: string,
  cookTime: string,
  cookTimeInNumber: number,
}
/*
Declared a couple of Regex
1st group: the whole number + variance of hour strings
2nd: the actual number
3rd: an alternative of possible hour/minute string 
*/
const hrReg = /((\d+)\s*(h|hr|hrs|hours))\b/i;
const minReg = /((\d+)\s*(m|min|mins|minutes))/i;

The is a matter of reducing the array

const withNumberTime = rec.reduce((final: Array<RecipeWithTime>, rec: Recipe): Array<RecipeWithTime> => {
  let h = rec.cookTime.match(hrReg);
  let m = rec.cookTime.match(minReg);
  let time = 0;
  if(h) {
    let [full,first,hours] = h;
    time += parseInt(hours, 10) * 60
  }
  if(m) {
    let [full, first, min] = m;
    time += parseInt(min, 10);
  }
  const n: RecipeWithTime = Object.assign(
    {},
    rec,
    {cookTimeInNumber: time}
    )
  return [...final, n]
}, [])

To end up with:

[ { recipeName: 'Baked Eggs',
    cookTime: '1 hr 25 mins',
    cookTimeInNumber: 85 },
  { recipeName: 'Scrambled Eggs',
    cookTime: '5 mins',
    cookTimeInNumber: 5 },
  { recipeName: 'Pumpkin Soup',
    cookTime: '45 mins',
    cookTimeInNumber: 45 } ]

Hope this is not totally out of topic :smile:
Thanks for the fun :+1:

2 Likes

Wow this is cool!
Thanks so much for sharing your solution :slight_smile:

And thanks everyone for helping out! I have now been able to solve this and move on - it’s starting to come alone nicely :slight_smile:

@Marmiz I liked your approach and condensed it a bit more with plain JavaScript (no TypeScript).

const hoursRegexStr = '((?<hours>\\d+)\\s*(h|hr|hrs|hours))?';
const minsRegexStr = '((?<mins>\\d+)\\s*(m|min|mins|minutes)|$)';
const timeRegex = new RegExp(hoursRegexStr + '\\s*' + minsRegexStr);

const withNumberTime = recipes.reduce((final, recipe) => {
  const { hours = 0, mins = 0 } = recipe.cookTime.match(timeRegex).groups;
  const cookTimeNumeric = +hours * 60 + +mins;
  return [ ...final , { ...recipe, cookTimeNumeric } ];
}, []);
3 Likes

That is a really nice combined RegExp @RandellDawson :clap:

Was thinking about this, and the issue is that if this is an external API, and results are being filtered, there is possibly a timing issue that’s dependent on how you are searching in the data, and there’s definitely an efficiency issue regardless.

If the app is set up to search for recipe names, then you filter those results, it’s feasible, if inefficient, to get the results, then convert them, then run the filter.

If you are running a filter directly against the API (ie searching for recipes based on timings), then that’s not possible, because you need the data before you can convert, and the data doesn’t exist until you convert.

I would go back to your original idea, use a version of the conversion function discussed above, and run it in the search filter against every datum, regardless of which option is closest to your situation.

(Note also that if you control the API, use minutes or seconds in the data and humanise that to like 1hr 25mins at the point it’s rendered on the screen, don’t store it as humanised values.)

Thanks for the detailed response, I appreciate it!

Actually, the data is coming from a wordpress blog where the recipe data is parsed from a schema that contains things like recipe name, cook time, ingredients, etc, etc. Everything comes in from the WP API as a HTML string and I go about pulling apart what I need from the HTML.

It’s inefficient, as I’m pulling in all posts before breaking down what I need - but for demonstration purposes it works OK. I was thinking of using Indexed DB so that future requests wouldn’t actually have to come from the WP API … @DanCouper do you think that is a possible option for greater performance on future visits?

Yea that’s not a bad idea at all. It would be used as a cache, so process would be check dB first, make request if data isn’t there, and as the data comes in, save it to the dB in the converted form. And always render the dB data rather than the raw response data.

Possibly best, although more work, is to have a proxy of some kind server side that you own
So you may have another three hops: request goes to proxy. If proxy doesn’t have the data, proxy requests data from WP blog. Blog returns data to proxy. Proxy does the work of parsing the data and saves data on a dB installed alongside the proxy. Proxy returns data.

Both approaches suffer the same problem: how quickly does the cache data go stale (how often do new recipes get added to or updated the original site: both things will invalidate your data)? But then that’s one of the apocryphal hard problems (“there are two hard problems in computer science: cache invalidation, naming things and off-by-one errors”)

You can go on forever here, there are a million and one permutations. For example, say you use IndexedDB. If there is some matching data in your cache, you use that. But now, on another thread, trigger a fetch for data from the WP blog. Once that returns, if that contains fresh data, update the cache in the IndexedDB on your main thread.

1 Like